├── .rspec ├── spec ├── dummy │ ├── log │ │ └── .keep │ ├── tmp │ │ └── .keep │ ├── db │ │ └── test.sqlite3 │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── javascripts │ │ │ │ ├── channels │ │ │ │ │ └── .keep │ │ │ │ ├── cable.js │ │ │ │ └── application.js │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_record.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── jobs │ │ │ ├── application_job.rb │ │ │ ├── test_job.rb │ │ │ ├── test_no_args_job.rb │ │ │ ├── test_worker.rb │ │ │ ├── test_no_args_worker.rb │ │ │ ├── test_optional_args_job.rb │ │ │ └── test_optional_args_worker.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ └── views │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ ├── rails │ │ ├── update │ │ └── setup │ ├── config │ │ ├── spring.rb │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── initializers │ │ │ ├── session_store.rb │ │ │ ├── mime_types.rb │ │ │ ├── application_controller_renderer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── wrap_parameters.rb │ │ │ └── inflections.rb │ │ ├── boot.rb │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── secrets.yml │ │ ├── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ │ └── puma.rb │ ├── config.ru │ └── Rakefile ├── simple_scheduler │ ├── config │ │ ├── erb_test.yml │ │ ├── hourly_task.yml │ │ ├── active_job.yml │ │ ├── sidekiq_worker.yml │ │ ├── queue_ahead_global.yml │ │ ├── queue_ahead_per_task.yml │ │ └── custom_queue_name.yml │ ├── scheduler_job_spec.rb │ ├── at_spec.rb │ ├── future_job_spec.rb │ └── task_spec.rb ├── coverage_helper.rb ├── rails_helper.rb └── spec_helper.rb ├── lib ├── simple_scheduler │ ├── version.rb │ ├── railtie.rb │ ├── scheduler_job.rb │ ├── future_job.rb │ ├── at.rb │ └── task.rb ├── tasks │ └── simple_scheduler_tasks.rake └── simple_scheduler.rb ├── gemfiles ├── latest.gemfile ├── rails_7.gemfile ├── rails_8.gemfile └── rails_8_1.gemfile ├── CHANGELOG.md ├── .gitignore ├── config └── simple_scheduler.yml ├── .github └── workflows │ ├── tests-schedule.yml │ └── tests.yml ├── Rakefile ├── Appraisals ├── .rubocop.yml ├── Gemfile ├── MIT-LICENSE ├── SECURITY.MD ├── simple_scheduler.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/db/test.sqlite3: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/erb_test.yml: -------------------------------------------------------------------------------- 1 | <%= "test_job:\n class: TestJob\n every: 1.day\n at: \"*:00\"" %> 2 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/hourly_task.yml: -------------------------------------------------------------------------------- 1 | hourly_task: 2 | class: "TestJob" 3 | every: "1.hour" 4 | at: "*:00" 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/active_job.yml: -------------------------------------------------------------------------------- 1 | weekly_task: 2 | class: "TestJob" 3 | every: "1.week" 4 | at: "Sun 1:00" 5 | -------------------------------------------------------------------------------- /lib/simple_scheduler/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleScheduler 4 | VERSION = "2.0.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/sidekiq_worker.yml: -------------------------------------------------------------------------------- 1 | weekly_task: 2 | class: "TestWorker" 3 | every: "1.week" 4 | at: "Sun 1:00" 5 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_job.rb: -------------------------------------------------------------------------------- 1 | # Active Job for testing 2 | class TestJob < ApplicationJob 3 | def perform(scheduled_time); end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/queue_ahead_global.yml: -------------------------------------------------------------------------------- 1 | queue_ahead: 120 2 | 3 | hourly_task: 4 | class: "TestJob" 5 | every: "1.hour" 6 | at: "*:00" 7 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/queue_ahead_per_task.yml: -------------------------------------------------------------------------------- 1 | hourly_task: 2 | class: "TestJob" 3 | every: "1.hour" 4 | at: "*:00" 5 | queue_ahead: 180 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_no_args_job.rb: -------------------------------------------------------------------------------- 1 | # Active Job for testing a job with no args 2 | class TestNoArgsJob < ApplicationJob 3 | def perform; end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ).each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /spec/simple_scheduler/config/custom_queue_name.yml: -------------------------------------------------------------------------------- 1 | queue_name: "custom" 2 | 3 | weekly_task: 4 | class: "TestJob" 5 | every: "1.week" 6 | at: "Sun 1:00" 7 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_worker.rb: -------------------------------------------------------------------------------- 1 | # Sidekiq Worker for testing 2 | class TestWorker 3 | include Sidekiq::Worker 4 | def perform(scheduled_time); end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /gemfiles/latest.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob" 6 | gem "sidekiq" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Simple Scheduler 2 | 3 | The changelog for Simple Scheduler is maintained in the [GitHub Releases](https://github.com/simplymadeapps/simple_scheduler/releases). 4 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_no_args_worker.rb: -------------------------------------------------------------------------------- 1 | # Sidekiq Worker for testing a worker with no args 2 | class TestNoArgsWorker 3 | include Sidekiq::Worker 4 | def perform; end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 7.2" 6 | gem "sidekiq", "~> 7.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /gemfiles/rails_8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 8.0.0" 6 | gem "sidekiq", "~> 8.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_8_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activejob", "~> 8.1.0" 6 | gem "sidekiq", "~> 8.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_optional_args_job.rb: -------------------------------------------------------------------------------- 1 | # Active Job for testing a job with optional args 2 | class TestOptionalArgsJob < ApplicationJob 3 | def perform(scheduled_time, options = nil); end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .bundle/ 3 | coverage 4 | gemfiles/*.lock 5 | log/*.log 6 | pkg/ 7 | spec/dummy/db/*.sqlite3 8 | spec/dummy/db/*.sqlite3-journal 9 | spec/dummy/log/*.log 10 | spec/dummy/tmp/ 11 | Gemfile.lock 12 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/test_optional_args_worker.rb: -------------------------------------------------------------------------------- 1 | # Sidekiq Worker for testing a worker with optional args 2 | class TestOptionalArgsWorker 3 | include Sidekiq::Worker 4 | def perform(scheduled_time, options = nil); end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ApplicationController.renderer.defaults.merge!( 4 | # http_host: 'example.org', 5 | # https: false 6 | # ) 7 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /config/simple_scheduler.yml: -------------------------------------------------------------------------------- 1 | # Example file used by scheduler_job_spec.rb as the default config path. 2 | job_one: 3 | class: "TestJob" 4 | every: "1.week" 5 | at: "Sun 1:00" 6 | 7 | job_two: 8 | class: "TestJob" 9 | every: "1.week" 10 | at: "Sun 1:00" 11 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /lib/simple_scheduler/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleScheduler 4 | # Load the rake task into the Rails app 5 | class Railtie < Rails::Railtie 6 | rake_tasks do 7 | load File.join(File.dirname(__FILE__), "../tasks/simple_scheduler_tasks.rake") 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.github/workflows/tests-schedule.yml: -------------------------------------------------------------------------------- 1 | name: tests schedule 2 | on: 3 | schedule: 4 | # Run at 06:20 UTC every Monday (weekly) 5 | - cron: "20 6 * * 1" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | run-tests: 10 | if: ${{ github.repository == 'simplymadeapps/simple-scheduler' }} 11 | uses: ./.github/workflows/tests.yml 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "bundler/setup" 5 | rescue LoadError 6 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 7 | end 8 | 9 | begin 10 | require "rspec/core/rake_task" 11 | RSpec::Core::RakeTask.new(:spec) 12 | task default: :spec 13 | rescue LoadError 14 | puts "Could not load Rspec Rake task" 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | 7 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> 8 | <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "rails-7" do 4 | gem "activejob", "~> 7.2" 5 | gem "sidekiq", "~> 7.0" 6 | end 7 | 8 | appraise "rails-8" do 9 | gem "activejob", "~> 8.0.0" 10 | gem "sidekiq", "~> 8.0" 11 | end 12 | 13 | appraise "rails-8-1" do 14 | gem "activejob", "~> 8.1.0" 15 | gem "sidekiq", "~> 8.0" 16 | end 17 | 18 | appraise "latest" do 19 | gem "activejob" 20 | gem "sidekiq" 21 | end 22 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the rails generate channel command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/coverage_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | require "simplecov-rcov" 5 | 6 | # return non-zero if not met 7 | SimpleCov.at_exit do 8 | SimpleCov.minimum_coverage 100 9 | SimpleCov.result.format! 10 | end 11 | 12 | SimpleCov.start do 13 | add_filter "lib/simple_scheduler/railtie" 14 | add_filter "/spec/" 15 | end 16 | 17 | # Format the reports in a way I like 18 | SimpleCov.formatter = SimpleCov::Formatter::RcovFormatter 19 | -------------------------------------------------------------------------------- /lib/tasks/simple_scheduler_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Queue future jobs defined using Simple Scheduler" 4 | task simple_scheduler: :environment do 5 | SimpleScheduler::SchedulerJob.perform_now 6 | end 7 | 8 | namespace :simple_scheduler do 9 | desc "Delete existing scheduled jobs and queue them from scratch" 10 | task reset: :environment do 11 | SimpleScheduler::FutureJob.delete_all 12 | SimpleScheduler::SchedulerJob.perform_now 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "action_controller/railtie" 4 | require "rails/test_unit/railtie" 5 | 6 | Bundler.require(*Rails.groups) 7 | require "simple_scheduler" 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | # Settings in config/environments/* take precedence over those specified here. 12 | # Application configuration should go into files in config/initializers 13 | # -- all .rb files in that directory are automatically loaded. 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "spec/dummy/**/*" 4 | - "vendor/bundle/**/*" 5 | - "gemfiles/**/*" 6 | NewCops: enable 7 | TargetRubyVersion: 3.2 8 | 9 | Gemspec/DevelopmentDependencies: 10 | EnforcedStyle: gemspec 11 | 12 | Layout/FirstArrayElementIndentation: 13 | Enabled: false 14 | 15 | Layout/LineLength: 16 | Max: 120 17 | Exclude: 18 | - "spec/simple_scheduler/task_spec.rb" 19 | 20 | Metrics/BlockLength: 21 | Exclude: 22 | - "spec/**/*" 23 | 24 | Style/StringLiterals: 25 | EnforcedStyle: double_quotes 26 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Declare your gem's dependencies in simple_scheduler.gemspec. 6 | # Bundler will treat runtime dependencies like base dependencies, and 7 | # development dependencies will be added by default to the :development group. 8 | gemspec 9 | 10 | # Declare any dependencies that are still in development here instead of in 11 | # your gemspec. These might include edge Rails or gems from your path or 12 | # Git. Remember to move these dependencies to your gemspec before releasing 13 | # your gem to rubygems.org. 14 | 15 | # To use a debugger 16 | # gem 'byebug', group: [:development, :test] 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is copied to spec/ when you run 'rails generate rspec:install' 4 | ENV["RAILS_ENV"] ||= "test" 5 | require "coverage_helper" 6 | require File.expand_path("../spec/dummy/config/environment.rb", __dir__) 7 | # Prevent database truncation if the environment is production 8 | abort("The Rails environment is running in production mode!") if Rails.env.production? 9 | require "spec_helper" 10 | require "rspec/rails" 11 | require "sidekiq/testing" 12 | 13 | RSpec.configure do |config| 14 | config.include ActiveJob::TestHelper 15 | config.include ActiveSupport::Testing::Assertions 16 | config.include ActiveSupport::Testing::TimeHelpers 17 | config.filter_rails_from_backtrace! 18 | 19 | ActiveJob::Base.logger = Rails.logger 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | workflow_call: 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: [3.2.9, 3.3.10, 3.4.7] 16 | env: 17 | RAILS_ENV: test 18 | steps: 19 | - uses: actions/checkout@v5 20 | 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 25 | 26 | - uses: supercharge/redis-github-action@1.8.0 27 | 28 | - name: Appraisal Install 29 | run: bundle exec appraisal install 30 | 31 | - name: Rubocop 32 | run: bundle exec rubocop 33 | 34 | - name: Rspec 35 | run: bundle exec appraisal rspec 36 | -------------------------------------------------------------------------------- /spec/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 9a9bad6c0223f7c2ceea86dc88f89fb937dddcaf96a7cad962531c5dc2ed4205292d2dfce3dd50d89da1a88f134d4611bf53640c0552c7f99754a8582b9248ae 15 | 16 | test: 17 | secret_key_base: d1df70a21d97a010cf817b37f628c14449420253de7063b37fee5990ddfdc8fd5a2d39dba3b73ad8a7704c3ac4686d6d5f4c1b6575e73e62cdf807265d6d79f6 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # puts "\n== Copying sample files ==" 22 | # unless File.exist?('config/database.yml') 23 | # cp 'config/database.yml.sample', 'config/database.yml' 24 | # end 25 | 26 | puts "\n== Preparing database ==" 27 | system! 'bin/rails db:setup' 28 | 29 | puts "\n== Removing old logs and tempfiles ==" 30 | system! 'bin/rails log:clear tmp:clear' 31 | 32 | puts "\n== Restarting application server ==" 33 | system! 'bin/rails restart' 34 | end 35 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Brian Pattison 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/simple_scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_job" 4 | require "sidekiq/api" 5 | require_relative "simple_scheduler/at" 6 | require_relative "simple_scheduler/future_job" 7 | require_relative "simple_scheduler/railtie" 8 | require_relative "simple_scheduler/scheduler_job" 9 | require_relative "simple_scheduler/task" 10 | require_relative "simple_scheduler/version" 11 | 12 | # Module for scheduling jobs at specific times using Sidekiq. 13 | module SimpleScheduler 14 | # Used by a Rails initializer to handle expired tasks. 15 | # SimpleScheduler.expired_task do |exception| 16 | # ExceptionNotifier.notify_exception( 17 | # exception, 18 | # data: { 19 | # task: exception.task.name, 20 | # scheduled: exception.scheduled_time, 21 | # actual: exception.run_time 22 | # } 23 | # ) 24 | # end 25 | def self.expired_task(&block) 26 | expired_task_blocks << block 27 | end 28 | 29 | # Blocks that should be called when a task doesn't run because it has expired. 30 | # @return [Array] 31 | def self.expired_task_blocks 32 | @expired_task_blocks ||= [] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /SECURITY.MD: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | We value the security community and believe that responsible disclosure of security vulnerabilities in open source packages helps us ensure the security and privacy of the users. 4 | 5 | If you believe you have found a security vulnerability, we encourage you to let us know right away. We will investigate all legitimate reports and do our best to quickly fix the problem. Before reporting though, please review our responsible disclosure policy, and those things that should not be reported. 6 | 7 | Submit your report to help@simplymadeapps.com (one issue per report) and respond to the report with any updates. Please do not contact employees directly or through other channels about a report. 8 | 9 | Report security bugs in third-party modules to the person or team maintaining the module. 10 | 11 | 12 | ### Responsible Disclosure Policy 13 | 14 | We ask that: 15 | 16 | * You give us reasonable time to investigate and mitigate an issue you report before making public any information about the report or sharing such information with others. 17 | * You do not exploit a security issue you discover for any reason. (This includes demonstrating additional risk, such as attempted compromise of sensitive company data or probing for additional issues). 18 | * You do not violate any other applicable laws or regulations. 19 | -------------------------------------------------------------------------------- /simple_scheduler.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("lib", __dir__) 4 | 5 | # Maintain your gem's version: 6 | require "simple_scheduler/version" 7 | 8 | # Describe your gem and declare its dependencies: 9 | Gem::Specification.new do |s| 10 | s.name = "simple_scheduler" 11 | s.version = SimpleScheduler::VERSION 12 | s.authors = ["Brian Pattison"] 13 | s.email = ["brian@brianpattison.com"] 14 | s.homepage = "https://github.com/simplymadeapps/simple_scheduler" 15 | s.summary = "An enhancement for Heroku Scheduler + Sidekiq for scheduling jobs at specific times." 16 | s.description = <<-DESCRIPTION 17 | Simple Scheduler adds the ability to enhance Heroku Scheduler by using Sidekiq to queue 18 | jobs in the future. This allows for defining specific run times (Ex: Every Sunday at 4 AM) 19 | and running tasks more often than Heroku Scheduler's 10 minute limit. 20 | DESCRIPTION 21 | s.license = "MIT" 22 | 23 | s.metadata["rubygems_mfa_required"] = "true" 24 | s.required_ruby_version = ">= 3.2.0" 25 | 26 | s.files = Dir["{lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 27 | 28 | s.add_dependency "activejob", ">= 7.2" 29 | s.add_dependency "sidekiq", ">= 7.0" 30 | 31 | s.add_development_dependency "appraisal" 32 | s.add_development_dependency "rainbow" 33 | s.add_development_dependency "rspec-rails", "~> 8" 34 | s.add_development_dependency "rubocop", "~> 1.28" 35 | s.add_development_dependency "simplecov" 36 | s.add_development_dependency "simplecov-rcov" 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | # config.public_file_server.enabled = true 17 | # config.public_file_server.headers = { 18 | # 'Cache-Control' => 'public, max-age=3600' 19 | # } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Print deprecation notices to the stderr. 32 | config.active_support.deprecation = :stderr 33 | 34 | # Raises error for missing translations 35 | # config.action_view.raise_on_missing_translations = true 36 | end 37 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 4 | RSpec.configure do |config| 5 | # rspec-expectations config goes here. You can use an alternate 6 | # assertion/expectation library such as wrong or the stdlib/minitest 7 | # assertions if you prefer. 8 | config.expect_with :rspec do |expectations| 9 | # This option will default to `true` in RSpec 4. It makes the `description` 10 | # and `failure_message` of custom matchers include text for helper methods 11 | # defined using `chain`, e.g.: 12 | # be_bigger_than(2).and_smaller_than(4).description 13 | # # => "be bigger than 2 and smaller than 4" 14 | # ...rather than: 15 | # # => "be bigger than 2" 16 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 17 | end 18 | 19 | # rspec-mocks config goes here. You can use an alternate test double 20 | # library (such as bogus or mocha) by changing the `mock_with` option here. 21 | config.mock_with :rspec do |mocks| 22 | # Prevents you from mocking or stubbing a method that does not exist on 23 | # a real object. This is generally recommended, and will default to 24 | # `true` in RSpec 4. 25 | mocks.verify_partial_doubles = true 26 | end 27 | 28 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 29 | # have no way to turn it off -- the option exists only for backwards 30 | # compatibility in RSpec 3). It causes shared context metadata to be 31 | # inherited by the metadata hash of host groups and examples, rather than 32 | # triggering implicit auto-inclusion in groups with matching metadata. 33 | config.shared_context_metadata_behavior = :apply_to_host_groups 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join('tmp/caching-dev.txt').exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => 'public, max-age=172800' 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Print deprecation notices to the Rails logger. 30 | config.active_support.deprecation = :log 31 | 32 | # Raise an error on page load if there are pending migrations. 33 | config.active_record.migration_error = :page_load 34 | 35 | # Debug mode disables concatenation and preprocessing of assets. 36 | # This option may cause significant delays in view rendering with a large 37 | # number of complex assets. 38 | config.assets.debug = true 39 | 40 | # Suppress logger output for asset requests. 41 | config.assets.quiet = true 42 | 43 | # Raises error for missing translations 44 | # config.action_view.raise_on_missing_translations = true 45 | 46 | # Use an evented file watcher to asynchronously detect changes in source code, 47 | # routes, locales, etc. This feature depends on the listen gem. 48 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 49 | end 50 | -------------------------------------------------------------------------------- /lib/simple_scheduler/scheduler_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleScheduler 4 | # Active Job class that queues jobs defined in the config file. 5 | class SchedulerJob < ActiveJob::Base 6 | def perform 7 | load_config 8 | queue_future_jobs 9 | end 10 | 11 | private 12 | 13 | # Returns the path of the Simple Scheduler configuration file. 14 | # @return [String] 15 | def config_path 16 | ENV.fetch("SIMPLE_SCHEDULER_CONFIG", "config/simple_scheduler.yml") 17 | end 18 | 19 | # Load the global scheduler config from the YAML file. 20 | def load_config 21 | @config = YAML.safe_load(ERB.new(File.read(config_path)).result) 22 | @queue_ahead = @config["queue_ahead"] || Task::DEFAULT_QUEUE_AHEAD_MINUTES 23 | @queue_name = @config["queue_name"] || "default" 24 | @time_zone = @config["tz"] || Time.zone.tzinfo.name 25 | @config.delete("queue_ahead") 26 | @config.delete("queue_name") 27 | @config.delete("tz") 28 | end 29 | 30 | # Queue each of the future jobs into Sidekiq from the defined tasks. 31 | def queue_future_jobs 32 | tasks.each do |task| 33 | # Schedule the new run times using the future job wrapper. 34 | new_run_times = task.future_run_times - task.existing_run_times 35 | new_run_times.each do |time| 36 | SimpleScheduler::FutureJob.set(queue: @queue_name, wait_until: time) 37 | .perform_later(task.params, time.to_i) 38 | end 39 | end 40 | end 41 | 42 | # The array of tasks loaded from the config YAML. 43 | # @return [Array path)) 12 | end 13 | 14 | describe "successfully queues" do 15 | subject(:job) { described_class.perform_later } 16 | 17 | it "queues the job" do 18 | expect { job }.to change(enqueued_jobs, :size).by(1) 19 | end 20 | 21 | it "is in default queue" do 22 | expect(described_class.new.queue_name).to eq("default") 23 | end 24 | end 25 | 26 | describe "scheduling tasks without specifying a config path" do 27 | it "queues the jobs loaded from config/simple_scheduler.yml" do 28 | travel_to(now) do 29 | expect do 30 | described_class.perform_now 31 | end.to change(enqueued_jobs, :size).by(4) 32 | end 33 | end 34 | end 35 | 36 | describe "Sidekiq queue name" do 37 | it "uses 'default' as the queue name if `queue_name` isn't set in the config" do 38 | travel_to(now) do 39 | described_class.perform_now 40 | expect(enqueued_jobs.first[:queue]).to eq("default") 41 | end 42 | end 43 | 44 | it "uses the custom queue name from the config file when adding FutureJob" do 45 | config_path("spec/simple_scheduler/config/custom_queue_name.yml") 46 | travel_to(now) do 47 | described_class.perform_now 48 | expect(enqueued_jobs.first[:queue]).to eq("custom") 49 | end 50 | end 51 | end 52 | 53 | describe "loading a YML file with ERB tags" do 54 | it "parses the file and queues the jobs" do 55 | config_path("spec/simple_scheduler/config/erb_test.yml") 56 | travel_to(now) do 57 | expect do 58 | described_class.perform_now 59 | end.to change(enqueued_jobs, :size).by(2) 60 | end 61 | end 62 | end 63 | 64 | describe "scheduling an hourly task" do 65 | it "queues jobs for at least six hours into the future by default" do 66 | config_path("spec/simple_scheduler/config/hourly_task.yml") 67 | travel_to(now) do 68 | expect do 69 | described_class.perform_now 70 | end.to change(enqueued_jobs, :size).by(7) 71 | end 72 | end 73 | 74 | it "respects the queue_ahead global option" do 75 | config_path("spec/simple_scheduler/config/queue_ahead_global.yml") 76 | travel_to(now) do 77 | expect do 78 | described_class.perform_now 79 | end.to change(enqueued_jobs, :size).by(3) 80 | end 81 | end 82 | 83 | it "respects the queue_ahead option per task" do 84 | config_path("spec/simple_scheduler/config/queue_ahead_per_task.yml") 85 | travel_to(now) do 86 | expect do 87 | described_class.perform_now 88 | end.to change(enqueued_jobs, :size).by(4) 89 | end 90 | end 91 | end 92 | 93 | describe "scheduling a weekly task" do 94 | it "always queues two future jobs" do 95 | config_path("spec/simple_scheduler/config/active_job.yml") 96 | travel_to(now) do 97 | expect do 98 | described_class.perform_now 99 | end.to change(enqueued_jobs, :size).by(2) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/simple_scheduler/at.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "delegate" 4 | 5 | module SimpleScheduler 6 | # A delegator that parses the :at option on a task into the first time it should run. 7 | # Instead of inheriting from Time (which conflicts with ActiveSupport time helpers), 8 | # this wraps a Time instance and selectively overrides hour/hour? semantics. 9 | # Time.now 10 | # # => 2016-12-09 08:24:11 -0600 11 | # SimpleScheduler::At.new("*:30") 12 | # # => 2016-12-09 08:30:00 -0600 13 | # SimpleScheduler::At.new("1:00") 14 | # # => 2016-12-10 01:00:00 -0600 15 | # SimpleScheduler::At.new("Sun 0:00") 16 | # # => 2016-12-11 00:00:00 -0600 17 | class At < SimpleDelegator 18 | AT_PATTERN = /\A(Sun|Mon|Tue|Wed|Thu|Fri|Sat)?\s?(?:\*{1,2}|((?:\b[0-1]?[0-9]|2[0-3]))):([0-5]\d)\z/ 19 | DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze 20 | 21 | # Error class raised when an invalid string is given for the time. 22 | class InvalidTime < StandardError; end 23 | 24 | # Accepts a time string to determine when a task should be run for the first time. 25 | # Valid formats: 26 | # "18:00" 27 | # "3:30" 28 | # "**:00" 29 | # "*:30" 30 | # "Sun 2:00" 31 | # "[Sun|Mon|Tue|Wed|Thu|Fri|Sat] 00:00" 32 | # @param at [String] The formatted string for a task's run time 33 | # @param time_zone [ActiveSupport::TimeZone] The time zone to parse the at time in 34 | def initialize(at, time_zone = nil) 35 | @at = at 36 | @time_zone = time_zone || Time.zone 37 | super(parsed_time) # Delegate all Time methods to parsed Time instance 38 | end 39 | 40 | # Returns the specified hour if present in the at string, else the delegated Time's hour. 41 | # @return [Integer] 42 | def hour 43 | hour? ? at_hour : __getobj__.hour 44 | end 45 | 46 | # Returns whether or not the hour was specified in the :at string. 47 | # @return [Boolean] 48 | def hour? 49 | at_match[2].present? 50 | end 51 | 52 | private 53 | 54 | def at_match 55 | @at_match ||= begin 56 | raise InvalidTime, "The `at` option is required." if @at.nil? 57 | 58 | match = AT_PATTERN.match(@at) 59 | raise InvalidTime, "The `at` option '#{@at}' is invalid." if match.nil? 60 | 61 | match 62 | end 63 | end 64 | 65 | def at_hour 66 | @at_hour ||= (at_match[2] || now.hour).to_i 67 | end 68 | 69 | def at_min 70 | @at_min ||= (at_match[3] || now.min).to_i 71 | end 72 | 73 | def at_wday 74 | @at_wday ||= DAYS.index(at_match[1]) 75 | end 76 | 77 | def at_wday? 78 | at_match[1].present? 79 | end 80 | 81 | def next_hour 82 | @next_hour ||= begin 83 | h = at_hour 84 | # Add an additional hour if a specific hour wasn't given, if the minutes 85 | # given are less than the current time's minutes. 86 | h += 1 if next_hour? 87 | h 88 | end 89 | end 90 | 91 | def next_hour? 92 | !hour? && at_min < now.min 93 | end 94 | 95 | def now 96 | @now ||= @time_zone.now.beginning_of_minute 97 | end 98 | 99 | def parsed_day 100 | day = now.beginning_of_day 101 | 102 | # If no day of the week is given, return today 103 | return day unless at_wday? 104 | 105 | # Shift to the correct day of the week if given 106 | add_days = at_wday - day.wday 107 | add_days += 7 if day.wday > at_wday 108 | day + add_days.days 109 | end 110 | 111 | # Returns the very first time a job should be run for the scheduled task. 112 | # @return [Time] 113 | def parsed_time 114 | return @parsed_time if defined?(@parsed_time) && @parsed_time 115 | 116 | time_object = parsed_day 117 | change_hour = next_hour 118 | # There is no hour 24, so we need to move to the next day 119 | if change_hour == 24 120 | time_object = 1.day.from_now(time_object) 121 | change_hour = 0 122 | end 123 | time_object = time_object.change(hour: change_hour, min: at_min) 124 | 125 | # If the parsed time is still before the current time, add an additional day if 126 | # the week day wasn't specified or add an additional week to get the correct time. 127 | time_object += at_wday? ? 1.week : 1.day if now > time_object 128 | 129 | @parsed_time = time_object 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/simple_scheduler/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleScheduler 4 | # Class for parsing each task in the scheduler config YAML file and returning 5 | # the values needed to schedule the task in the future. 6 | # 7 | # @!attribute [r] job_class 8 | # @return [Class] The class of the job or worker. 9 | # @!attribute [r] params 10 | # @return [Hash] The params used to create the task 11 | class Task 12 | attr_reader :job_class, :params 13 | 14 | DEFAULT_QUEUE_AHEAD_MINUTES = 360 15 | 16 | # Initializes a task by parsing the params so the task can be queued in the future. 17 | # @param params [Hash] 18 | # @option params [String] :class The class of the Active Job or Sidekiq Worker 19 | # @option params [String] :every How frequently the job will be performed 20 | # @option params [String] :at The starting time for the interval 21 | # @option params [String] :expires_after The interval used to determine how late the job is allowed to run 22 | # @option params [Integer] :queue_ahead The number of minutes that jobs should be queued in the future 23 | # @option params [String] :task_name The name of the task as defined in the YAML config 24 | # @option params [String] :tz The time zone to use when parsing the `at` option 25 | def initialize(params) 26 | validate_params!(params) 27 | @params = params 28 | end 29 | 30 | # The task's first run time as a Time-like object. 31 | # @return [SimpleScheduler::At] 32 | def at 33 | @at ||= At.new(@params[:at], time_zone) 34 | end 35 | 36 | # The time between the scheduled and actual run time that should cause the job not to run. 37 | # @return [String] 38 | def expires_after 39 | @params[:expires_after] 40 | end 41 | 42 | # Returns an array of existing jobs matching the job class of the task. 43 | # @return [Array] 44 | def existing_jobs 45 | @existing_jobs ||= SimpleScheduler::Task.scheduled_set.select do |job| 46 | next unless job.display_class == "SimpleScheduler::FutureJob" 47 | 48 | task_params = job.display_args[0].symbolize_keys 49 | task_params[:class] == job_class_name && task_params[:name] == name 50 | end.to_a 51 | end 52 | 53 | # Returns an array of existing future run times that have already been scheduled. 54 | # @return [Array