diff --git a/Gemfile b/Gemfile index 65299204..a6b3536f 100644 --- a/Gemfile +++ b/Gemfile @@ -154,6 +154,9 @@ gem 'activerecord-import' # Get env variables from .env file gem 'dotenv-rails' +# Cron job scheduling +gem 'whenever' + group :development, :test do # Run specs in parallel gem 'parallel_tests' diff --git a/Gemfile.lock b/Gemfile.lock index 349cdaee..1a4612da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,7 @@ GEM cheat (1.3.3) pager (~> 1.0) choice (0.2.0) + chronic (0.10.2) codecov (0.2.12) json simplecov @@ -524,6 +525,8 @@ GEM websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + whenever (1.0.0) + chronic (>= 0.6.3) zeitwerk (2.5.1) PLATFORMS @@ -607,6 +610,7 @@ DEPENDENCIES vcr web-console webmock + whenever BUNDLED WITH 2.3.7 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index d9668320..aaec98f5 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -96,7 +96,7 @@ class Exercise < ApplicationRecord scope :can_release_to_a15k, -> { where(release_to_a15k: true) } scope :not_released_to_a15k, -> { where(a15k_identifier: nil) } - before_validation :set_context, :set_slug_tags + before_validation :set_context, :set_slug_tags! def content_equals?(other_exercise) return false unless other_exercise.is_a? ActiveRecord::Base @@ -247,7 +247,7 @@ def set_context(archive_version: nil) return end - def set_slug_tags + def set_slug_tags! existing_book_slug_tags, other_tags = tags.partition do |tag| tag.name.starts_with? 'book-slug:' end @@ -268,10 +268,10 @@ def set_slug_tags "module-slug:#{slug[:book]}:#{slug[:page]}" end - kept_book_slug_tags = existing_book_slug_tags.filter do |tag| + kept_book_slug_tags, removed_book_slug_tags = existing_book_slug_tags.partition do |tag| desired_book_slugs.include? tag.name end - kept_page_slug_tags = existing_page_slug_tags.filter do |tag| + kept_page_slug_tags, removed_page_slug_tags = existing_page_slug_tags.partition do |tag| desired_page_slugs.include? tag.name end @@ -284,5 +284,11 @@ def set_slug_tags self.tags = non_slug_tags + kept_book_slug_tags + kept_page_slug_tags + new_book_slugs + new_page_slugs + + # Return whether or not any tags changed + !removed_book_slug_tags.empty? || + !removed_page_slug_tags.empty? || + !new_book_slugs.empty? || + !new_page_slugs.empty? end end diff --git a/app/routines/update_slugs.rb b/app/routines/update_slugs.rb new file mode 100644 index 00000000..5049650a --- /dev/null +++ b/app/routines/update_slugs.rb @@ -0,0 +1,16 @@ +class UpdateSlugs + lev_routine transaction: :no_transaction + + protected + + def exec + Exercise.preload(:publication).in_batches(of: 100, load: true) do |exercises| + Exercise.transaction do + updated_exercise_ids = exercises.select(:id).filter(&:set_slug_tags!).map(&:id) + next if updated_exercise_ids.empty? + + Exercise.where(id: updated_exercise_ids).update_all updated_at: Time.current + end + end + end +end diff --git a/config/schedule.rb b/config/schedule.rb new file mode 100644 index 00000000..e6ba411e --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,5 @@ +# Server time is UTC +# Times below are interpreted that way + +every(1.minute) { rake 'cron:minute' } +every(1.day, at: '8 AM') { rake 'cron:day' } # Midnight-1AM Pacific/2-3AM Central/3-4AM Eastern diff --git a/db/migrate/20220302162344_create_slug_tags.rb b/db/migrate/20220302162344_create_slug_tags.rb index 3732575a..68e8376a 100644 --- a/db/migrate/20220302162344_create_slug_tags.rb +++ b/db/migrate/20220302162344_create_slug_tags.rb @@ -4,7 +4,7 @@ class CreateSlugTags < ActiveRecord::Migration[6.1] def up Exercise.preload(:publication).in_batches(of: 100, load: true) do |exercises| Exercise.transaction do - exercises.each(&:set_slug_tags) + exercises.each(&:set_slug_tags!) exercises.update_all updated_at: Time.current end end diff --git a/lib/tasks/cron/day.rake b/lib/tasks/cron/day.rake new file mode 100644 index 00000000..889bdbdf --- /dev/null +++ b/lib/tasks/cron/day.rake @@ -0,0 +1,10 @@ +namespace :cron do + task day: :log_to_stdout do + Rails.logger.debug 'Starting daily cron' + + Rails.logger.info 'UpdateSlugs.call' + OpenStax::RescueFrom.this { UpdateSlugs.call } + + Rails.logger.debug 'Finished daily cron' + end +end diff --git a/lib/tasks/cron/minute.rake b/lib/tasks/cron/minute.rake new file mode 100644 index 00000000..63088fce --- /dev/null +++ b/lib/tasks/cron/minute.rake @@ -0,0 +1,10 @@ +namespace :cron do + task minute: :log_to_stdout do + Rails.logger.debug 'Starting minute cron' + + Rails.logger.info 'rake openstax:accounts:sync:accounts' + OpenStax::RescueFrom.this { Rake::Task['openstax:accounts:sync:accounts'].invoke } + + Rails.logger.debug 'Finished minute cron' + end +end diff --git a/lib/tasks/log_to_stdout.rake b/lib/tasks/log_to_stdout.rake new file mode 100644 index 00000000..5be5bc23 --- /dev/null +++ b/lib/tasks/log_to_stdout.rake @@ -0,0 +1,20 @@ +# http://jerryclinesmith.me/blog/2014/01/16/logging-from-rake-tasks/ +desc 'Include this task as another task\'s dependency to cause Rails.logger to also print to STDOUT' +task :log_to_stdout, [ :log_level ] => :environment do |tt, args| + # Do nothing in the test environment, since we don't want stdout there + next if Rails.env.test? + + # Clone the main Rails logger to dissociate it from the other module loggers + # so we don't receive database, background job and mailer logs + Rails.logger = Rails.logger.clone + + stdout_logger = ActiveSupport::Logger.new(STDOUT) + + # By default, use a log level of at least 1 so we don't receive debug messages + stdout_logger.level = args.fetch(:log_level) do + ENV.fetch('LOG_LEVEL') { [ Rails.logger.level, 1 ].max } + end + stdout_logger.formatter = Rails.logger.formatter + + Rails.logger.extend(ActiveSupport::Logger.broadcast(stdout_logger)) +end diff --git a/spec/lib/tasks/cron/day.rake_spec.rb b/spec/lib/tasks/cron/day.rake_spec.rb new file mode 100644 index 00000000..c5bdbadc --- /dev/null +++ b/spec/lib/tasks/cron/day.rake_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'cron:day', type: :rake do + before :all do + Rake.application.rake_require 'tasks/cron/day' + Rake::Task.define_task :log_to_stdout + end + + before { Rake::Task['cron:day'].reenable } + + it 'calls the UpdateSlugs lev routine' do + expect(UpdateSlugs).to receive(:call) + + Rake.application.invoke_task 'cron:day' + end +end diff --git a/spec/lib/tasks/cron/minute.rake_spec.rb b/spec/lib/tasks/cron/minute.rake_spec.rb new file mode 100644 index 00000000..8546e762 --- /dev/null +++ b/spec/lib/tasks/cron/minute.rake_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'cron:minute', type: :rake do + before :all do + Rake.application.rake_require 'tasks/cron/minute' + Rake::Task.define_task :log_to_stdout + end + + before { Rake::Task['cron:minute'].reenable } + + let!(:task) { instance_double(Rake::Task) } + + it 'calls the openstax:accounts:sync rake task' do + expect(Rake::Task).to receive(:[]).with('openstax:accounts:sync:accounts').and_return(task) + expect(task).to receive(:invoke) + + Rake.application.invoke_task 'cron:minute' + end +end diff --git a/spec/models/exercise_spec.rb b/spec/models/exercise_spec.rb index 2785abdc..50e62331 100644 --- a/spec/models/exercise_spec.rb +++ b/spec/models/exercise_spec.rb @@ -8,7 +8,8 @@ it { is_expected.to have_many(:exercise_tags).dependent(:destroy) } it 'automatically sets the context based on tags and rewrites image links' do - expect_any_instance_of(Exercise).to receive(:set_slug_tags).twice + # Disable set_slug_tags! + expect_any_instance_of(Exercise).to receive(:set_slug_tags!).twice exercise.tags = [ 'context-cnxmod:4ee317f2-cc23-4075-b377-51ee4d11bb61', diff --git a/spec/routines/exercises/tag/xlsx_spec.rb b/spec/routines/exercises/tag/xlsx_spec.rb index 1955d7eb..8a065c7b 100644 --- a/spec/routines/exercises/tag/xlsx_spec.rb +++ b/spec/routines/exercises/tag/xlsx_spec.rb @@ -20,8 +20,8 @@ let!(:exercises) { (1..6).map { |ii| FactoryBot.create(:publication, number: ii).publishable } } - # Disable set_slug_tags - before { allow_any_instance_of(Exercise).to receive(:set_slug_tags) } + # Disable set_slug_tags! + before { allow_any_instance_of(Exercise).to receive(:set_slug_tags!) } it 'tags exercises with the sample spreadsheet' do expect { described_class.call(filename: fixture_path) }.to change { ExerciseTag.count }.by(20) diff --git a/spec/routines/exercises/untag/xlsx_spec.rb b/spec/routines/exercises/untag/xlsx_spec.rb index 7dfc6f0a..ed1685a8 100644 --- a/spec/routines/exercises/untag/xlsx_spec.rb +++ b/spec/routines/exercises/untag/xlsx_spec.rb @@ -2,8 +2,8 @@ RSpec.describe Exercises::Untag::Xlsx, type: :routine do before do - # Disable set_slug_tags - allow_any_instance_of(Exercise).to receive(:set_slug_tags) + # Disable set_slug_tags! + allow_any_instance_of(Exercise).to receive(:set_slug_tags!) @exercises = (1..6).map { |ii| FactoryBot.create(:publication, number: ii).publishable } diff --git a/spec/routines/update_slugs_spec.rb b/spec/routines/update_slugs_spec.rb new file mode 100644 index 00000000..f6d0dcaa --- /dev/null +++ b/spec/routines/update_slugs_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe UpdateSlugs, type: :routine do + let!(:exercise) { FactoryBot.create :exercise } + let!(:updated_exercise) { FactoryBot.create :exercise } + + before do + allow_any_instance_of(Exercise).to( + receive(:set_slug_tags!) { |exercise| exercise.id == updated_exercise.id } + ) + end + + it 'calls set_slug_tags! on all exercises and sets updated_at for updated exercises' do + expect { described_class.call }.to change { updated_exercise.reload.updated_at } + .and not_change { exercise.reload.updated_at } + end +end