├── Gemfile ├── .gitignore ├── Rakefile ├── gemfiles ├── activesupport4.gemfile └── activesupport5.gemfile ├── bin ├── clockwork └── clockworkd ├── CHANGELOG.md ├── test ├── samples │ └── signal_test.rb ├── database_events │ ├── event_store_test.rb │ ├── support │ │ └── active_record_fake.rb │ ├── test_helpers.rb │ └── synchronizer_test.rb ├── signal_test.rb ├── event_test.rb ├── clockwork_test.rb ├── at_test.rb └── manager_test.rb ├── .travis.yml ├── example.rb ├── lib ├── clockwork │ ├── database_events │ │ ├── manager.rb │ │ ├── event.rb │ │ ├── synchronizer.rb │ │ ├── event_collection.rb │ │ └── event_store.rb │ ├── database_events.rb │ ├── at.rb │ ├── event.rb │ └── manager.rb └── clockwork.rb ├── clockwork.gemspec ├── LICENSE ├── clockworkd.1 └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | tmp 3 | /.bundle 4 | Gemfile.lock 5 | .ruby-version 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |t| 5 | t.test_files = FileList['test/**/*_test.rb'] 6 | t.verbose = false 7 | end 8 | 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /gemfiles/activesupport4.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | platforms :rbx do 4 | gem 'rubysl', '~> 2.0' 5 | gem 'rubysl-test-unit' 6 | gem 'rubinius-developer_tools' 7 | end 8 | 9 | gem 'activesupport', '~> 4.2' 10 | gem 'minitest', '~> 5.0' 11 | gemspec :path=>"../" 12 | -------------------------------------------------------------------------------- /gemfiles/activesupport5.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | platforms :rbx do 4 | gem 'rubysl', '~> 2.0' 5 | gem 'rubysl-test-unit' 6 | gem 'rubinius-developer_tools' 7 | end 8 | 9 | gem 'activesupport', '~> 5.0' 10 | gem 'minitest', '~> 5.0' 11 | gemspec :path=>"../" 12 | -------------------------------------------------------------------------------- /bin/clockwork: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | STDERR.sync = STDOUT.sync = true 4 | 5 | require 'clockwork' 6 | 7 | usage = 'Usage: clockwork ' 8 | file = ARGV.shift or abort usage 9 | 10 | file = "./#{file}" unless file.match(/^[\/.]/) 11 | 12 | require file 13 | 14 | Clockwork::run 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.4 (Unreleased) ## 2 | 3 | * Reverts the breaking changes in PR #18 that went out in patch 2.0.3 4 | 5 | *Javier Julio* 6 | 7 | ## 2.0.3 (February 15, 2018) ## 8 | 9 | * [See the version release](https://github.com/Rykian/clockwork/releases) for the commits that were included. 10 | -------------------------------------------------------------------------------- /test/samples/signal_test.rb: -------------------------------------------------------------------------------- 1 | require 'clockwork' 2 | require 'active_support/time' 3 | 4 | module Clockwork 5 | LOGFILE = File.expand_path('../../tmp/signal_test.log', __FILE__) 6 | 7 | handler do |job| 8 | File.write(LOGFILE, 'start') 9 | sleep 0.1 10 | File.write(LOGFILE, 'done') 11 | end 12 | 13 | configure do |config| 14 | config[:sleep_timeout] = 0 15 | config[:logger] = Logger.new(StringIO.new) 16 | end 17 | 18 | every(1.seconds, 'run.me.every.1.seconds') 19 | end 20 | 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | before_install: 5 | - gem update --system 6 | # This is required to support ActiveSupport 4 7 | # https://docs.travis-ci.com/user/languages/ruby/#bundler-20 8 | - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true 9 | - gem install bundler -v '< 2' 10 | rvm: 11 | - 2.3.8 12 | - 2.4.5 13 | - 2.5.3 14 | - jruby-9.1.17.0 15 | - jruby-9.2.6.0 16 | gemfile: 17 | - gemfiles/activesupport4.gemfile 18 | - gemfiles/activesupport5.gemfile 19 | -------------------------------------------------------------------------------- /example.rb: -------------------------------------------------------------------------------- 1 | require 'clockwork' 2 | 3 | module Clockwork 4 | handler do |job| 5 | puts "Queueing job: #{job}" 6 | end 7 | 8 | every(10.seconds, 'run.me.every.10.seconds') 9 | every(1.minute, 'run.me.every.minute') 10 | every(1.hour, 'run.me.every.hour') 11 | 12 | every(1.day, 'run.me.at.midnight', :at => '00:00') 13 | 14 | every(1.day, 'custom.event.handler', :at => '00:30') do 15 | puts 'This event has its own handler' 16 | end 17 | 18 | # note: callbacks that return nil or false will cause event to not run 19 | on(:before_tick) do 20 | puts "tick" 21 | true 22 | end 23 | 24 | on(:after_tick) do 25 | puts "tock" 26 | true 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/clockwork/database_events/manager.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | 3 | module DatabaseEvents 4 | 5 | class Manager < Clockwork::Manager 6 | 7 | def unregister(event) 8 | @events.delete(event) 9 | end 10 | 11 | def register(period, job, block, options) 12 | @events << if options[:from_database] 13 | synchronizer = options.fetch(:synchronizer) 14 | model_attributes = options.fetch(:model_attributes) 15 | 16 | Clockwork::DatabaseEvents::Event. 17 | new(self, period, job, (block || handler), synchronizer, model_attributes, options) 18 | else 19 | Clockwork::Event.new(self, period, job, block || handler, options) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/database_events/event_store_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require 'clockwork/database_events/event_store' 3 | require 'clockwork/database_events/event_collection' 4 | 5 | describe Clockwork::DatabaseEvents::EventStore do 6 | 7 | described_class = Clockwork::DatabaseEvents::EventStore 8 | EventCollection = Clockwork::DatabaseEvents::EventCollection 9 | 10 | describe '#register' do 11 | it 'adds the event to the event group' do 12 | event_group = EventCollection.new 13 | EventCollection.stubs(:new).returns(event_group) 14 | 15 | event = OpenStruct.new 16 | model = OpenStruct.new id: 1 17 | subject = described_class.new(Proc.new {}) 18 | 19 | event_group.expects(:add).with(event) 20 | 21 | subject.register(event, model) 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/clockwork/database_events/event.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | 3 | module DatabaseEvents 4 | 5 | class Event < Clockwork::Event 6 | 7 | attr_accessor :event_store, :model_attributes 8 | 9 | def initialize(manager, period, job, block, event_store, model_attributes, options={}) 10 | super(manager, period, job, block, options) 11 | @event_store = event_store 12 | @event_store.register(self, job) 13 | @model_attributes = model_attributes 14 | end 15 | 16 | def name 17 | (job_has_name? && job.name) ? job.name : "#{job.class}:#{job.id}" 18 | end 19 | 20 | def job_has_name? 21 | job.respond_to?(:name) 22 | end 23 | 24 | def to_s 25 | name 26 | end 27 | 28 | def frequency 29 | @period 30 | end 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/clockwork/database_events/synchronizer.rb: -------------------------------------------------------------------------------- 1 | require_relative '../database_events' 2 | 3 | module Clockwork 4 | 5 | module DatabaseEvents 6 | 7 | class Synchronizer 8 | 9 | def self.setup(options={}, &block_to_perform_on_event_trigger) 10 | model_class = options.fetch(:model) { raise KeyError, ":model must be set to the model class" } 11 | every = options.fetch(:every) { raise KeyError, ":every must be set to the database sync frequency" } 12 | 13 | event_store = EventStore.new(block_to_perform_on_event_trigger) 14 | 15 | # create event that syncs clockwork events with events coming from database-backed model 16 | Clockwork.manager.every every, "sync_database_events_for_model_#{model_class}" do 17 | event_store.update(model_class.all) 18 | end 19 | end 20 | end 21 | 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/clockwork/database_events.rb: -------------------------------------------------------------------------------- 1 | require_relative 'database_events/event' 2 | require_relative 'database_events/synchronizer' 3 | require_relative 'database_events/event_store' 4 | require_relative 'database_events/manager' 5 | 6 | # TERMINOLOGY 7 | # 8 | # For clarity, we have chosen to define terms as follows for better communication in the code, and when 9 | # discussing the database event implementation. 10 | # 11 | # "Event": "Native" Clockwork events, whether Clockwork::Event or Clockwork::DatabaseEvents::Event 12 | # "Model": Database-backed model instances representing events to be created in Clockwork 13 | 14 | module Clockwork 15 | 16 | module Methods 17 | def sync_database_events(options={}, &block) 18 | DatabaseEvents::Synchronizer.setup(options, &block) 19 | end 20 | end 21 | 22 | extend Methods 23 | 24 | module DatabaseEvents 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/signal_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'mocha/setup' 3 | require 'fileutils' 4 | 5 | class SignalTest < Test::Unit::TestCase 6 | CMD = File.expand_path('../../bin/clockwork', __FILE__) 7 | SAMPLE = File.expand_path('../samples/signal_test.rb', __FILE__) 8 | LOGFILE = File.expand_path('../tmp/signal_test.log', __FILE__) 9 | 10 | setup do 11 | FileUtils.mkdir_p(File.dirname(LOGFILE)) 12 | @pid = spawn(CMD, SAMPLE) 13 | until File.exist?(LOGFILE) 14 | sleep 0.1 15 | end 16 | end 17 | 18 | teardown do 19 | FileUtils.rm_r(File.dirname(LOGFILE)) 20 | end 21 | 22 | test 'should gracefully shutdown with SIGTERM' do 23 | Process.kill(:TERM, @pid) 24 | sleep 0.2 25 | assert_equal 'done', File.read(LOGFILE) 26 | end 27 | 28 | test 'should forcely shutdown with SIGINT' do 29 | Process.kill(:INT, @pid) 30 | sleep 0.2 31 | assert_equal 'start', File.read(LOGFILE) 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /clockwork.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "clockwork" 3 | s.version = "2.0.3" 4 | 5 | s.authors = ["Adam Wiggins", "tomykaira"] 6 | s.license = 'MIT' 7 | s.description = "A scheduler process to replace cron, using a more flexible Ruby syntax running as a single long-running process. Inspired by rufus-scheduler and resque-scheduler." 8 | s.email = ["adam@heroku.com", "tomykaira@gmail.com"] 9 | s.extra_rdoc_files = [ 10 | "README.md" 11 | ] 12 | s.homepage = "http://github.com/Rykian/clockwork" 13 | s.summary = "A scheduler process to replace cron." 14 | 15 | s.files = `git ls-files`.split($/) 16 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 18 | s.require_paths = ["lib"] 19 | 20 | s.add_dependency(%q) 21 | s.add_dependency(%q) 22 | 23 | s.add_development_dependency "rake" 24 | s.add_development_dependency "daemons" 25 | s.add_development_dependency "minitest", "~> 5.8" 26 | s.add_development_dependency "mocha" 27 | s.add_development_dependency "test-unit" 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010-2014 Adam Wiggins, tomykaira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/clockwork.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'active_support/time' 3 | 4 | require 'clockwork/at' 5 | require 'clockwork/event' 6 | require 'clockwork/manager' 7 | 8 | module Clockwork 9 | class << self 10 | def included(klass) 11 | klass.send "include", Methods 12 | klass.extend Methods 13 | end 14 | 15 | def manager 16 | @manager ||= Manager.new 17 | end 18 | 19 | def manager=(manager) 20 | @manager = manager 21 | end 22 | end 23 | 24 | module Methods 25 | def configure(&block) 26 | Clockwork.manager.configure(&block) 27 | end 28 | 29 | def handler(&block) 30 | Clockwork.manager.handler(&block) 31 | end 32 | 33 | def error_handler(&block) 34 | Clockwork.manager.error_handler(&block) 35 | end 36 | 37 | def on(event, options={}, &block) 38 | Clockwork.manager.on(event, options, &block) 39 | end 40 | 41 | def every(period, job, options={}, &block) 42 | Clockwork.manager.every(period, job, options, &block) 43 | end 44 | 45 | def run 46 | Clockwork.manager.run 47 | end 48 | 49 | def clear! 50 | Clockwork.manager = Manager.new 51 | end 52 | end 53 | 54 | extend Methods 55 | end 56 | -------------------------------------------------------------------------------- /lib/clockwork/database_events/event_collection.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | module DatabaseEvents 3 | class EventCollection 4 | 5 | def initialize(manager=Clockwork.manager) 6 | @events = [] 7 | @manager = manager 8 | end 9 | 10 | def add(event) 11 | @events << event 12 | end 13 | 14 | def has_changed?(model) 15 | return true if event.nil? 16 | 17 | ignored_attributes = model.ignored_attributes if model.respond_to?(:ignored_attributes) 18 | ignored_attributes ||= [] 19 | 20 | model_attributes = model.attributes.select do |k, _| 21 | not ignored_attributes.include?(k.to_sym) 22 | end 23 | 24 | event.model_attributes != model_attributes 25 | end 26 | 27 | def unregister 28 | events.each{|e| manager.unregister(e) } 29 | end 30 | 31 | private 32 | 33 | attr_reader :events, :manager 34 | 35 | # All events in the same collection (for a model instance) are equivalent 36 | # so we can use any of them. Only their @at variable will be different, 37 | # but we don't care about that here. 38 | def event 39 | events.first 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/database_events/support/active_record_fake.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecordFake 2 | 3 | def self.included(base) 4 | base.instance_variable_set(:@items, []) 5 | base.instance_variable_set(:@next_id, 1) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | 10 | attr_accessor :id 11 | 12 | def initialize options={} 13 | @id = options.fetch(:id) { self.class.get_next_id } 14 | set_attribute_values_from_options options.reject{|key, value| key == :id } 15 | self.class.add self 16 | end 17 | 18 | def delete! 19 | self.class.remove(self) 20 | end 21 | 22 | def update options={} 23 | set_attribute_values_from_options options 24 | end 25 | 26 | def attributes 27 | Hash[instance_variables.map { |name| [name, instance_variable_get(name)] } ] 28 | end 29 | 30 | module ClassMethods 31 | def create *args 32 | new *args 33 | end 34 | 35 | def delete_all 36 | @items.clear 37 | reset_id 38 | true 39 | end 40 | 41 | def all 42 | @items.dup 43 | end 44 | 45 | 46 | def add instance 47 | @items << instance 48 | end 49 | 50 | def remove instance 51 | @items.delete(instance) 52 | end 53 | 54 | def get_next_id 55 | id = @next_id 56 | @next_id += 1 57 | id 58 | end 59 | 60 | def reset_id 61 | @next_id = 1 62 | end 63 | end 64 | 65 | private 66 | 67 | def set_attribute_values_from_options options 68 | options.each{|attr, value| self.send("#{attr}=".to_sym, value) } 69 | end 70 | end -------------------------------------------------------------------------------- /lib/clockwork/at.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | class At 3 | class FailedToParse < StandardError; end 4 | 5 | NOT_SPECIFIED = nil 6 | WDAYS = %w[sunday monday tuesday wednesday thursday friday saturday].each.with_object({}).with_index do |(w, wdays), index| 7 | [w, w.capitalize, w[0...3], w[0...3].capitalize].each do |k| 8 | wdays[k] = index 9 | end 10 | end 11 | 12 | def self.parse(at) 13 | return unless at 14 | case at 15 | when /\A([[:alpha:]]+)\s(.*)\z/ 16 | if wday = WDAYS[$1] 17 | parsed_time = parse($2) 18 | parsed_time.wday = wday 19 | parsed_time 20 | else 21 | raise FailedToParse, at 22 | end 23 | when /\A(\d{1,2}):(\d\d)\z/ 24 | new($2.to_i, $1.to_i) 25 | when /\A\*{1,2}:(\d\d)\z/ 26 | new($1.to_i) 27 | when /\A(\d{1,2}):\*\*\z/ 28 | new(NOT_SPECIFIED, $1.to_i) 29 | else 30 | raise FailedToParse, at 31 | end 32 | rescue ArgumentError 33 | raise FailedToParse, at 34 | end 35 | 36 | attr_accessor :min, :hour, :wday 37 | 38 | def initialize(min, hour=NOT_SPECIFIED, wday=NOT_SPECIFIED) 39 | @min = min 40 | @hour = hour 41 | @wday = wday 42 | raise ArgumentError unless valid? 43 | end 44 | 45 | def ready?(t) 46 | (@min == NOT_SPECIFIED or t.min == @min) and 47 | (@hour == NOT_SPECIFIED or t.hour == @hour) and 48 | (@wday == NOT_SPECIFIED or t.wday == @wday) 49 | end 50 | 51 | def == other 52 | @min == other.min && @hour == other.hour && @wday == other.wday 53 | end 54 | 55 | private 56 | def valid? 57 | @min == NOT_SPECIFIED || (0..59).cover?(@min) && 58 | @hour == NOT_SPECIFIED || (0..23).cover?(@hour) && 59 | @wday == NOT_SPECIFIED || (0..6).cover?(@wday) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/database_events/test_helpers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'support/active_record_fake' 2 | 3 | def setup_sync(options={}) 4 | model_class = options.fetch(:model) { raise KeyError, ":model must be set to the model class" } 5 | frequency = options.fetch(:every) { raise KeyError, ":every must be set to the database sync frequency" } 6 | events_run = options.fetch(:events_run) { raise KeyError, ":events_run must be provided"} 7 | 8 | Clockwork::DatabaseEvents::Synchronizer.setup model: model_class, every: frequency do |model| 9 | name = model.respond_to?(:name) ? model.name : model.to_s 10 | events_run << name 11 | end 12 | end 13 | 14 | def assert_will_run(t) 15 | assert_equal 1, @manager.tick(normalize_time(t)).size 16 | end 17 | 18 | def assert_wont_run(t) 19 | assert_equal 0, @manager.tick(normalize_time(t)).size 20 | end 21 | 22 | def tick_at(now = Time.now, options = {}) 23 | seconds_to_tick_for = options[:and_every_second_for] || 0 24 | number_of_ticks = 1 + seconds_to_tick_for 25 | number_of_ticks.times{|i| @manager.tick(now + i) } 26 | end 27 | 28 | def next_minute(now = Time.now) 29 | Time.at((now.to_i / 60 + 1) * 60) 30 | end 31 | 32 | def normalize_time t 33 | t.is_a?(String) ? Time.parse(t) : t 34 | end 35 | 36 | 37 | class DatabaseEventModel 38 | include ActiveRecordFake 39 | attr_accessor :name, :frequency, :at, :tz 40 | 41 | def name 42 | @name || "#{self.class}:#{id}" 43 | end 44 | end 45 | 46 | class DatabaseEventModel2 47 | include ActiveRecordFake 48 | attr_accessor :name, :frequency, :at, :tz 49 | 50 | def name 51 | @name || "#{self.class}:#{id}" 52 | end 53 | end 54 | 55 | class DatabaseEventModelWithoutName 56 | include ActiveRecordFake 57 | attr_accessor :frequency, :at 58 | end 59 | 60 | class DatabaseEventModelWithIf 61 | include ActiveRecordFake 62 | attr_accessor :name, :frequency, :at, :tz, :if_state 63 | 64 | def name 65 | @name || "#{self.class}:#{id}" 66 | end 67 | 68 | def if?(time) 69 | @if_state 70 | end 71 | end -------------------------------------------------------------------------------- /clockworkd.1: -------------------------------------------------------------------------------- 1 | .TH CLOCKWORKD 1 "August 2014" "Ruby Gem" "clockwork" 2 | 3 | .SH NAME 4 | clockworkd - daemon executing clockwork scripts 5 | 6 | .SH SYNOPSIS 7 | \fBclockworkd\fR [-c \fIFILE\fR] [\fIOPTIONS\fR] {start|stop|restart|run} 8 | 9 | .SH DESCRIPTION 10 | \fBclockworkd\fR executes clockwork script as a daemon. 11 | 12 | You will need the \fBdaemons\fR gem to use \fBclockworkd\fR. It is not automatically installed, please install by yourself. 13 | 14 | .SH OPTIONS 15 | .TP 16 | \fB--pid-dir\fR=\fIDIR\fR 17 | Alternate directory in which to store the process ids. Default is \fIGEM_LOCATION\fR/tmp. 18 | 19 | .TP 20 | \fB-i\fR, \fB--identifier\fR=\fISTR\fR 21 | An identifier for the process. Default is clock file name. 22 | 23 | .TP 24 | \fB-l\fR, \fB--log\fR 25 | Redirect both STDOUT and STDERR to a logfile named clockworkd[.\fIidentifier\fR].output in the pid-file directory. 26 | 27 | .TP 28 | \fB--log-dir\fR=\fIDIR\fR 29 | A specific directory to put the log files into. Default location is pid directory. 30 | 31 | .TP 32 | \fB-m\fR, \fB--monitor\fR 33 | Start monitor process. 34 | 35 | .TP 36 | \fB-c\fR, \fB--clock\fR=\fIFILE\fR 37 | Clock .rb file. Default is \fIGEM_LOCATION\fR/clock.rb. 38 | 39 | .TP 40 | \fB-d\fR, \fB--dir\fR=\fIDIR\fR 41 | Directory to change to once the process starts 42 | 43 | .TP 44 | \fB-h\fR, \fB--help\fR 45 | Show help message. 46 | 47 | .SH BUGS 48 | If you find a bug, please create an issue \fIhttps://github.com/tomykaira/clockwork/issues\fR. 49 | 50 | For a bug fix or a feature request, please send a pull-request. Do not forget to add tests to show how your feature works, or what bug is fixed. All existing tests and new tests must pass (TravisCI is watching). 51 | 52 | .SH AUTHORS 53 | Created by Adam Wiggins. 54 | 55 | Inspired by rufus-scheduler and resque-scheduler. 56 | 57 | Design assistance from Peter van Hardenberg and Matthew Soldo. 58 | 59 | Patches contributed by Mark McGranaghan and Lukáš Konarovský. 60 | 61 | .SH SEE ALSO 62 | \fIhttps://github.com/tomykaira/clockwork\fR. 63 | -------------------------------------------------------------------------------- /lib/clockwork/event.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | class Event 3 | attr_accessor :job, :last 4 | 5 | def initialize(manager, period, job, block, options={}) 6 | validate_if_option(options[:if]) 7 | @manager = manager 8 | @period = period 9 | @job = job 10 | @at = At.parse(options[:at]) 11 | @block = block 12 | @if = options[:if] 13 | @thread = options.fetch(:thread, @manager.config[:thread]) 14 | @timezone = options.fetch(:tz, @manager.config[:tz]) 15 | @skip_first_run = options[:skip_first_run] 16 | @last = @skip_first_run ? convert_timezone(Time.now) : nil 17 | end 18 | 19 | def convert_timezone(t) 20 | @timezone ? t.in_time_zone(@timezone) : t 21 | end 22 | 23 | def run_now?(t) 24 | t = convert_timezone(t) 25 | return false unless elapsed_ready?(t) 26 | return false unless run_at?(t) 27 | return false unless run_if?(t) 28 | true 29 | end 30 | 31 | def thread? 32 | @thread 33 | end 34 | 35 | def run(t) 36 | @manager.log "Triggering '#{self}'" 37 | @last = convert_timezone(t) 38 | if thread? 39 | if @manager.thread_available? 40 | t = Thread.new do 41 | execute 42 | end 43 | t['creator'] = @manager 44 | else 45 | @manager.log_error "Threads exhausted; skipping #{self}" 46 | end 47 | else 48 | execute 49 | end 50 | end 51 | 52 | def to_s 53 | job.to_s 54 | end 55 | 56 | private 57 | def execute 58 | @block.call(@job, @last) 59 | rescue => e 60 | @manager.log_error e 61 | @manager.handle_error e 62 | end 63 | 64 | def elapsed_ready?(t) 65 | @last.nil? || (t - @last.to_i).to_i >= @period 66 | end 67 | 68 | def run_at?(t) 69 | @at.nil? || @at.ready?(t) 70 | end 71 | 72 | def run_if?(t) 73 | @if.nil? || @if.call(t) 74 | end 75 | 76 | def validate_if_option(if_option) 77 | if if_option && !if_option.respond_to?(:call) 78 | raise ArgumentError.new(':if expects a callable object, but #{if_option} does not respond to call') 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/event_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../lib/clockwork', __FILE__) 2 | require "minitest/autorun" 3 | 4 | describe Clockwork::Event do 5 | describe '#thread?' do 6 | before do 7 | @manager = Class.new 8 | end 9 | 10 | describe 'manager config thread option set to true' do 11 | before do 12 | @manager.stubs(:config).returns({ :thread => true }) 13 | end 14 | 15 | it 'is true' do 16 | event = Clockwork::Event.new(@manager, nil, nil, nil) 17 | assert_equal true, event.thread? 18 | end 19 | 20 | it 'is false when event thread option set' do 21 | event = Clockwork::Event.new(@manager, nil, nil, nil, :thread => false) 22 | assert_equal false, event.thread? 23 | end 24 | end 25 | 26 | describe 'manager config thread option not set' do 27 | before do 28 | @manager.stubs(:config).returns({}) 29 | end 30 | 31 | it 'is true if event thread option is true' do 32 | event = Clockwork::Event.new(@manager, nil, nil, nil, :thread => true) 33 | assert_equal true, event.thread? 34 | end 35 | end 36 | end 37 | 38 | describe '#run_now?' do 39 | before do 40 | @manager = Class.new 41 | @manager.stubs(:config).returns({}) 42 | end 43 | 44 | describe 'event skip_first_run option set to true' do 45 | it 'returns false on first attempt' do 46 | event = Clockwork::Event.new(@manager, 1, nil, nil, :skip_first_run => true) 47 | assert_equal false, event.run_now?(Time.now) 48 | end 49 | 50 | it 'returns true on subsequent attempts' do 51 | event = Clockwork::Event.new(@manager, 1, nil, nil, :skip_first_run => true) 52 | # first run 53 | event.run_now?(Time.now) 54 | 55 | # second run 56 | assert_equal true, event.run_now?(Time.now + 1) 57 | end 58 | end 59 | 60 | describe 'event skip_first_run option not set' do 61 | it 'returns true on first attempt' do 62 | event = Clockwork::Event.new(@manager, 1, nil, nil) 63 | assert_equal true, event.run_now?(Time.now + 1) 64 | end 65 | end 66 | 67 | describe 'event skip_first_run option set to false' do 68 | it 'returns true on first attempt' do 69 | event = Clockwork::Event.new(@manager, 1, nil, nil, :skip_first_run => false) 70 | assert_equal true, event.run_now?(Time.now) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/clockwork_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../lib/clockwork', __FILE__) 2 | require 'minitest/autorun' 3 | require 'mocha/setup' 4 | 5 | describe Clockwork do 6 | before do 7 | @log_output = StringIO.new 8 | Clockwork.configure do |config| 9 | config[:sleep_timeout] = 0 10 | config[:logger] = Logger.new(@log_output) 11 | end 12 | IO.stubs(:select) 13 | end 14 | 15 | after do 16 | Clockwork.clear! 17 | end 18 | 19 | it 'should run events with configured logger' do 20 | run = false 21 | Clockwork.handler do |job| 22 | run = job == 'myjob' 23 | end 24 | Clockwork.every(1.minute, 'myjob') 25 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 26 | Clockwork.run 27 | 28 | assert run 29 | assert @log_output.string.include?('Triggering') 30 | end 31 | 32 | it 'should log event correctly' do 33 | run = false 34 | Clockwork.handler do |job| 35 | run = job == 'an event' 36 | end 37 | Clockwork.every(1.minute, 'an event') 38 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 39 | Clockwork.run 40 | assert run 41 | assert @log_output.string.include?("Triggering 'an event'") 42 | end 43 | 44 | it 'should pass event without modification to handler' do 45 | event_object = Object.new 46 | run = false 47 | Clockwork.handler do |job| 48 | run = job == event_object 49 | end 50 | Clockwork.every(1.minute, event_object) 51 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 52 | Clockwork.run 53 | assert run 54 | end 55 | 56 | it 'should not run anything after reset' do 57 | Clockwork.every(1.minute, 'myjob') { } 58 | Clockwork.clear! 59 | Clockwork.configure do |config| 60 | config[:sleep_timeout] = 0 61 | config[:logger] = Logger.new(@log_output) 62 | end 63 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 64 | Clockwork.run 65 | assert @log_output.string.include?('0 events') 66 | end 67 | 68 | it 'should pass all arguments to every' do 69 | Clockwork.every(1.second, 'myjob', if: lambda { |_| false }) { } 70 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 71 | Clockwork.run 72 | assert @log_output.string.include?('1 events') 73 | assert !@log_output.string.include?('Triggering') 74 | end 75 | 76 | it 'support module re-open style' do 77 | $called = false 78 | module ::Clockwork 79 | every(1.second, 'myjob') { $called = true } 80 | end 81 | Clockwork.manager.stubs(:run_tick_loop).returns(Clockwork.manager.tick) 82 | Clockwork.run 83 | assert $called 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/at_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../lib/clockwork', __FILE__) 2 | require "minitest/autorun" 3 | require 'mocha/setup' 4 | require 'time' 5 | require 'active_support/time' 6 | 7 | describe 'Clockwork::At' do 8 | def time_in_day(hour, minute) 9 | Time.new(2013, 1, 1, hour, minute, 0) 10 | end 11 | 12 | it '16:20' do 13 | at = Clockwork::At.parse('16:20') 14 | assert !at.ready?(time_in_day(16, 19)) 15 | assert at.ready?(time_in_day(16, 20)) 16 | assert !at.ready?(time_in_day(16, 21)) 17 | end 18 | 19 | it '8:20' do 20 | at = Clockwork::At.parse('8:20') 21 | assert !at.ready?(time_in_day(8, 19)) 22 | assert at.ready?(time_in_day(8, 20)) 23 | assert !at.ready?(time_in_day(8, 21)) 24 | end 25 | 26 | it '**:20 with two stars' do 27 | at = Clockwork::At.parse('**:20') 28 | 29 | assert !at.ready?(time_in_day(15, 19)) 30 | assert at.ready?(time_in_day(15, 20)) 31 | assert !at.ready?(time_in_day(15, 21)) 32 | 33 | assert !at.ready?(time_in_day(16, 19)) 34 | assert at.ready?(time_in_day(16, 20)) 35 | assert !at.ready?(time_in_day(16, 21)) 36 | end 37 | 38 | it '*:20 with one star' do 39 | at = Clockwork::At.parse('*:20') 40 | 41 | assert !at.ready?(time_in_day(15, 19)) 42 | assert at.ready?(time_in_day(15, 20)) 43 | assert !at.ready?(time_in_day(15, 21)) 44 | 45 | assert !at.ready?(time_in_day(16, 19)) 46 | assert at.ready?(time_in_day(16, 20)) 47 | assert !at.ready?(time_in_day(16, 21)) 48 | end 49 | 50 | it '16:**' do 51 | at = Clockwork::At.parse('16:**') 52 | 53 | assert !at.ready?(time_in_day(15, 59)) 54 | assert at.ready?(time_in_day(16, 00)) 55 | assert at.ready?(time_in_day(16, 30)) 56 | assert at.ready?(time_in_day(16, 59)) 57 | assert !at.ready?(time_in_day(17, 00)) 58 | end 59 | 60 | it '8:**' do 61 | at = Clockwork::At.parse('8:**') 62 | 63 | assert !at.ready?(time_in_day(7, 59)) 64 | assert at.ready?(time_in_day(8, 00)) 65 | assert at.ready?(time_in_day(8, 30)) 66 | assert at.ready?(time_in_day(8, 59)) 67 | assert !at.ready?(time_in_day(9, 00)) 68 | end 69 | 70 | it 'Saturday 12:00' do 71 | at = Clockwork::At.parse('Saturday 12:00') 72 | 73 | assert !at.ready?(Time.new(2010, 1, 1, 12, 00)) 74 | assert at.ready?(Time.new(2010, 1, 2, 12, 00)) # Saturday 75 | assert !at.ready?(Time.new(2010, 1, 3, 12, 00)) 76 | assert at.ready?(Time.new(2010, 1, 9, 12, 00)) 77 | end 78 | 79 | it 'sat 12:00' do 80 | at = Clockwork::At.parse('sat 12:00') 81 | 82 | assert !at.ready?(Time.new(2010, 1, 1, 12, 00)) 83 | assert at.ready?(Time.new(2010, 1, 2, 12, 00)) 84 | assert !at.ready?(Time.new(2010, 1, 3, 12, 00)) 85 | end 86 | 87 | it 'invalid time 32:00' do 88 | assert_raises Clockwork::At::FailedToParse do 89 | Clockwork::At.parse('32:00') 90 | end 91 | end 92 | 93 | it 'invalid multi-line with Sat 12:00' do 94 | assert_raises Clockwork::At::FailedToParse do 95 | Clockwork::At.parse("sat 12:00\nreally invalid time") 96 | end 97 | end 98 | 99 | it 'invalid multi-line with 8:30' do 100 | assert_raises Clockwork::At::FailedToParse do 101 | Clockwork::At.parse("8:30\nreally invalid time") 102 | end 103 | end 104 | 105 | it 'invalid multi-line with *:10' do 106 | assert_raises Clockwork::At::FailedToParse do 107 | Clockwork::At.parse("*:10\nreally invalid time") 108 | end 109 | end 110 | 111 | it 'invalid multi-line with 12:**' do 112 | assert_raises Clockwork::At::FailedToParse do 113 | Clockwork::At.parse("12:**\nreally invalid time") 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /bin/clockworkd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | STDERR.sync = STDOUT.sync = true 4 | 5 | require 'clockwork' 6 | require 'optparse' 7 | require 'pathname' 8 | 9 | begin 10 | require 'daemons' 11 | rescue LoadError 12 | raise "You need to add gem 'daemons' to your Gemfile or Rubygems if you wish to use it." 13 | end 14 | 15 | @options = { 16 | :quiet => false, 17 | :pid_dir => File.expand_path("./tmp"), 18 | :log_output => false, 19 | :monitor => false, 20 | :file => File.expand_path("./clock.rb") 21 | } 22 | 23 | bin_basename = File.basename($0) 24 | 25 | opts = OptionParser.new do |opts| 26 | opts.banner = "Usage: #{bin_basename} -c FILE [options] start|stop|restart|run" 27 | opts.separator '' 28 | opts.on_tail('-h', '--help', 'Show this message') do 29 | puts opts 30 | exit 1 31 | end 32 | opts.on("--pid-dir=DIR", "Alternate directory in which to store the process ids. Default is #{@options[:pid_dir]}.") do |dir| 33 | @options[:pid_dir] = dir 34 | end 35 | opts.on('-i', '--identifier=STR', 'An identifier for the process. Default is clock file name.') do |n| 36 | @options[:identifier] = n 37 | end 38 | opts.on('-l', '--log', "Redirect both STDOUT and STDERR to a logfile named #{bin_basename}[.].output in the pid-file directory.") do 39 | @options[:log_output] = true 40 | end 41 | opts.on('--log-dir=DIR', 'A specific directory to put the log files into (default location is pid directory).') do | log_dir| 42 | @options[:log_dir] = File.expand_path(log_dir) 43 | end 44 | opts.on('-m', '--monitor', 'Start monitor process.') do 45 | @options[:monitor] = true 46 | end 47 | opts.on('-c', '--clock=FILE',"Clock .rb file. Default is #{@options[:file]}.") do |clock_file| 48 | @options[:file] = clock_file 49 | @options[:file] = "./#{@options[:file]}" unless Pathname.new(@options[:file]).absolute? 50 | @options[:file] = File.expand_path(@options[:file]) 51 | end 52 | opts.on('-d', '--dir=DIR', 'Directory to change to once the process starts') do |dir| 53 | @options[:current_dir] = File.expand_path(dir) 54 | end 55 | end 56 | 57 | @args = opts.parse!(ARGV) 58 | 59 | def optparser_abort(opts,message) 60 | $stderr.puts message 61 | puts opts 62 | exit 1 63 | end 64 | 65 | optparser_abort opts, "ERROR: --clock=FILE is required." unless @options[:file] 66 | optparser_abort opts, "ERROR: clock file #{@options[:file]} does not exist." unless File.exists?(@options[:file]) 67 | optparser_abort opts, "ERROR: File extension specified in --clock must be '.rb'" unless File.extname(@options[:file]) == ".rb" 68 | 69 | @options[:identifier] ||= "#{File.basename(@options[:file],'.*')}" 70 | process_name = "#{bin_basename}.#{@options[:identifier]}" 71 | 72 | @options[:log_dir] ||= @options[:pid_dir] 73 | 74 | Dir.mkdir(@options[:pid_dir]) unless File.exists?(@options[:pid_dir]) 75 | Dir.mkdir(@options[:log_dir]) unless File.exists?(@options[:log_dir]) 76 | 77 | puts "#{process_name}: pid file: #{File.expand_path(File.join(@options[:pid_dir],process_name + '.pid'))}" 78 | 79 | if @options[:log_output] 80 | puts "#{process_name}: output log file: #{File.expand_path(File.join(@options[:log_dir],process_name + '.output'))}" 81 | else 82 | puts "#{process_name}: No output will be printed out (run with --log if needed)" 83 | end 84 | 85 | Daemons.run_proc(process_name, :dir => @options[:pid_dir], :dir_mode => :normal, :monitor => @options[:monitor], :log_dir => @options[:log_dir], :log_output => @options[:log_output], :ARGV => @args) do |*args| 86 | # daemons changes the current working directory to '/' when a new process is 87 | # forked. We change it back to the project root directory here. 88 | Dir.chdir(@options[:current_dir]) if @options[:current_dir] 89 | require @options[:file] 90 | 91 | Clockwork::run 92 | end 93 | -------------------------------------------------------------------------------- /lib/clockwork/manager.rb: -------------------------------------------------------------------------------- 1 | module Clockwork 2 | class Manager 3 | class NoHandlerDefined < RuntimeError; end 4 | 5 | attr_reader :config 6 | 7 | def initialize 8 | @events = [] 9 | @callbacks = {} 10 | @config = default_configuration 11 | @handler = nil 12 | @mutex = Mutex.new 13 | @condvar = ConditionVariable.new 14 | @finish = false 15 | end 16 | 17 | def thread_available? 18 | Thread.list.select { |t| t['creator'] == self }.count < config[:max_threads] 19 | end 20 | 21 | def configure 22 | yield(config) 23 | if config[:sleep_timeout] < 1 24 | config[:logger].warn 'sleep_timeout must be >= 1 second' 25 | end 26 | end 27 | 28 | def default_configuration 29 | { :sleep_timeout => 1, :logger => Logger.new(STDOUT), :thread => false, :max_threads => 10 } 30 | end 31 | 32 | def handler(&block) 33 | @handler = block if block_given? 34 | raise NoHandlerDefined unless @handler 35 | @handler 36 | end 37 | 38 | def error_handler(&block) 39 | @error_handler = block if block_given? 40 | @error_handler 41 | end 42 | 43 | def on(event, options={}, &block) 44 | raise "Unsupported callback #{event}" unless [:before_tick, :after_tick, :before_run, :after_run].include?(event.to_sym) 45 | (@callbacks[event.to_sym]||=[]) << block 46 | end 47 | 48 | def every(period, job='unnamed', options={}, &block) 49 | if job.is_a?(Hash) and options.empty? 50 | options = job 51 | job = "unnamed" 52 | end 53 | if options[:at].respond_to?(:each) 54 | every_with_multiple_times(period, job, options, &block) 55 | else 56 | register(period, job, block, options) 57 | end 58 | end 59 | 60 | def fire_callbacks(event, *args) 61 | @callbacks[event].nil? || @callbacks[event].all? { |h| h.call(*args) } 62 | end 63 | 64 | def run 65 | log "Starting clock for #{@events.size} events: [ #{@events.map(&:to_s).join(' ')} ]" 66 | 67 | sig_read, sig_write = IO.pipe 68 | 69 | (%w[INT TERM HUP] & Signal.list.keys).each do |sig| 70 | trap sig do 71 | sig_write.puts(sig) 72 | end 73 | end 74 | 75 | run_tick_loop 76 | 77 | while io = IO.select([sig_read]) 78 | sig = io.first[0].gets.chomp 79 | handle_signal(sig) 80 | end 81 | end 82 | 83 | def handle_signal(sig) 84 | logger.debug "Got #{sig} signal" 85 | case sig 86 | when 'INT' 87 | shutdown 88 | when 'TERM' 89 | # Heroku sends TERM signal, and waits 10 seconds before exit 90 | graceful_shutdown 91 | when 'HUP' 92 | graceful_shutdown 93 | end 94 | end 95 | 96 | def shutdown 97 | logger.info 'Shutting down' 98 | stop_tick_loop 99 | exit(0) 100 | end 101 | 102 | def graceful_shutdown 103 | logger.info 'Gracefully shutting down' 104 | stop_tick_loop 105 | wait_tick_loop_finishes 106 | exit(0) 107 | end 108 | 109 | def stop_tick_loop 110 | @finish = true 111 | end 112 | 113 | def wait_tick_loop_finishes 114 | @mutex.synchronize do # wait by synchronize 115 | @condvar.signal 116 | end 117 | end 118 | 119 | def run_tick_loop 120 | Thread.new do 121 | @mutex.synchronize do 122 | until @finish 123 | tick 124 | interval = config[:sleep_timeout] - Time.now.subsec + 0.001 125 | @condvar.wait(@mutex, interval) if interval > 0 126 | end 127 | end 128 | end 129 | end 130 | 131 | def tick(t=Time.now) 132 | if (fire_callbacks(:before_tick)) 133 | events = events_to_run(t) 134 | events.each do |event| 135 | if (fire_callbacks(:before_run, event, t)) 136 | event.run(t) 137 | fire_callbacks(:after_run, event, t) 138 | end 139 | end 140 | end 141 | fire_callbacks(:after_tick) 142 | events 143 | end 144 | 145 | def logger 146 | config[:logger] 147 | end 148 | 149 | def log_error(e) 150 | config[:logger].error(e) 151 | end 152 | 153 | def handle_error(e) 154 | error_handler.call(e) if error_handler 155 | end 156 | 157 | def log(msg) 158 | config[:logger].info(msg) 159 | end 160 | 161 | private 162 | def events_to_run(t) 163 | @events.select{ |event| event.run_now?(t) } 164 | end 165 | 166 | def register(period, job, block, options) 167 | event = Event.new(self, period, job, block || handler, options) 168 | @events << event 169 | event 170 | end 171 | 172 | def every_with_multiple_times(period, job, options={}, &block) 173 | each_options = options.clone 174 | options[:at].each do |at| 175 | each_options[:at] = at 176 | register(period, job, block, each_options) 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/clockwork/database_events/event_store.rb: -------------------------------------------------------------------------------- 1 | require_relative './event_collection' 2 | 3 | # How EventStore and Clockwork manager events are kept in sync... 4 | # 5 | # The normal Clockwork::Manager is responsible for keeping track of 6 | # Clockwork events, and ensuring they are scheduled at the correct time. 7 | # It has an @events array for this purpose. 8 | 9 | # For keeping track of Database-backed events though, we need to keep 10 | # track of more information about the events, e.g. the block which should 11 | # be triggered when they are run, which model the event comes from, the 12 | # model ID it relates to etc. Therefore, we devised a separate mechanism 13 | # for keeping track of these database-backed events: the per-model EventStore. 14 | 15 | # Having two classes responsible for keeping track of events though leads to 16 | # a slight quirk, in that these two have to be kept in sync. The way this is 17 | # done is by letting the EventStore largely defer to the Clockwork Manager. 18 | 19 | # 1. When the EventStore wishes to recreate events: 20 | # - it asks the Clockwork.manager to do this for it 21 | # - by calling Clockwork.manager.every 22 | 23 | # 2. When the DatabaseEvents::Manager creates events (via its #register) 24 | # - it creates a new DatabaseEvents::Event 25 | # - DatabaseEvents::Event#initialize registers it with the EventStore 26 | module Clockwork 27 | 28 | module DatabaseEvents 29 | 30 | class EventStore 31 | 32 | def initialize(block_to_perform_on_event_trigger) 33 | @related_events = {} 34 | @block_to_perform_on_event_trigger = block_to_perform_on_event_trigger 35 | end 36 | 37 | # DatabaseEvents::Manager#register creates a new DatabaseEvents::Event, whose 38 | # #initialize method registers the new database event with the EventStore by 39 | # calling this method. 40 | def register(event, model) 41 | related_events_for(model.id).add(event) 42 | end 43 | 44 | def update(current_model_objects) 45 | unregister_all_except(current_model_objects) 46 | update_registered_models(current_model_objects) 47 | register_new_models(current_model_objects) 48 | end 49 | 50 | def unregister_all_except(model_objects) 51 | ids = model_objects.collect(&:id) 52 | (@related_events.keys - ids).each{|id| unregister(id) } 53 | end 54 | 55 | def update_registered_models(model_objects) 56 | registered_models(model_objects).each do |model| 57 | if has_changed?(model) 58 | unregister(model.id) 59 | register_with_manager(model) 60 | end 61 | end 62 | end 63 | 64 | def register_new_models(model_objects) 65 | unregistered_models(model_objects).each do |new_model_object| 66 | register_with_manager(new_model_object) 67 | end 68 | end 69 | 70 | private 71 | 72 | attr_reader :related_events 73 | 74 | def registered?(model) 75 | related_events_for(model.id) != nil 76 | end 77 | 78 | def has_changed?(model) 79 | related_events_for(model.id).has_changed?(model) 80 | end 81 | 82 | def related_events_for(id) 83 | related_events[id] ||= EventCollection.new 84 | end 85 | 86 | def registered_models(model_objects) 87 | model_objects.select{|m| registered?(m) } 88 | end 89 | 90 | def unregistered_models(model_objects) 91 | model_objects.select{|m| !registered?(m) } 92 | end 93 | 94 | def unregister(id) 95 | related_events_for(id).unregister 96 | related_events.delete(id) 97 | end 98 | 99 | # When re-creating events, the Clockwork.manager must be used to 100 | # create them, as it is ultimately responsible for ensuring that 101 | # the events actually get run when they should. We call its #every 102 | # method, which will result in DatabaseEvent::Manager#register being 103 | # called, which creates a new DatabaseEvent::Event, which will be 104 | # registered with the EventStore on #initialize. 105 | def register_with_manager(model) 106 | Clockwork.manager. 107 | every(model.frequency, model, options(model), 108 | &@block_to_perform_on_event_trigger) 109 | end 110 | 111 | def options(model) 112 | options = { 113 | :from_database => true, 114 | :synchronizer => self, 115 | :ignored_attributes => [], 116 | } 117 | 118 | options[:at] = at_strings_for(model) if model.respond_to?(:at) 119 | options[:if] = ->(time){ model.if?(time) } if model.respond_to?(:if?) 120 | options[:tz] = model.tz if model.respond_to?(:tz) 121 | options[:ignored_attributes] = model.ignored_attributes if model.respond_to?(:ignored_attributes) 122 | 123 | # store the state of the model at time of registering so we can 124 | # easily compare and determine if state has changed later 125 | options[:model_attributes] = model.attributes.select do |k, v| 126 | not options[:ignored_attributes].include?(k.to_sym) 127 | end 128 | 129 | options 130 | end 131 | 132 | def at_strings_for(model) 133 | return nil if model.at.to_s.empty? 134 | 135 | model.at.split(',').map(&:strip) 136 | end 137 | end 138 | 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/manager_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../lib/clockwork', __FILE__) 2 | require "minitest/autorun" 3 | require 'mocha/setup' 4 | require 'time' 5 | require 'active_support/time' 6 | 7 | describe Clockwork::Manager do 8 | before do 9 | @manager = Clockwork::Manager.new 10 | class << @manager 11 | def log(msg); end 12 | end 13 | @manager.handler { } 14 | end 15 | 16 | def assert_will_run(t) 17 | if t.is_a? String 18 | t = Time.parse(t) 19 | end 20 | assert_equal 1, @manager.tick(t).size 21 | end 22 | 23 | def assert_wont_run(t) 24 | if t.is_a? String 25 | t = Time.parse(t) 26 | end 27 | assert_equal 0, @manager.tick(t).size 28 | end 29 | 30 | it "once a minute" do 31 | @manager.every(1.minute, 'myjob') 32 | 33 | assert_will_run(t=Time.now) 34 | assert_wont_run(t+30) 35 | assert_will_run(t+60) 36 | end 37 | 38 | it "every three minutes" do 39 | @manager.every(3.minutes, 'myjob') 40 | 41 | assert_will_run(t=Time.now) 42 | assert_wont_run(t+2*60) 43 | assert_will_run(t+3*60) 44 | end 45 | 46 | it "once an hour" do 47 | @manager.every(1.hour, 'myjob') 48 | 49 | assert_will_run(t=Time.now) 50 | assert_wont_run(t+30*60) 51 | assert_will_run(t+60*60) 52 | end 53 | 54 | it "once a week" do 55 | @manager.every(1.week, 'myjob') 56 | 57 | assert_will_run(t=Time.now) 58 | assert_wont_run(t+60*60*24*6) 59 | assert_will_run(t+60*60*24*7) 60 | end 61 | 62 | it "won't drift later and later" do 63 | @manager.every(1.hour, 'myjob') 64 | 65 | assert_will_run(Time.parse("10:00:00.5")) 66 | assert_wont_run(Time.parse("10:59:59.999")) 67 | assert_will_run(Time.parse("11:00:00.0")) 68 | end 69 | 70 | it "aborts when no handler defined" do 71 | manager = Clockwork::Manager.new 72 | assert_raises(Clockwork::Manager::NoHandlerDefined) do 73 | manager.every(1.minute, 'myjob') 74 | end 75 | end 76 | 77 | it "aborts when fails to parse" do 78 | assert_raises(Clockwork::At::FailedToParse) do 79 | @manager.every(1.day, "myjob", :at => "a:bc") 80 | end 81 | end 82 | 83 | it "general handler" do 84 | $set_me = 0 85 | @manager.handler { $set_me = 1 } 86 | @manager.every(1.minute, 'myjob') 87 | @manager.tick(Time.now) 88 | assert_equal 1, $set_me 89 | end 90 | 91 | it "event-specific handler" do 92 | $set_me = 0 93 | @manager.every(1.minute, 'myjob') { $set_me = 2 } 94 | @manager.tick(Time.now) 95 | 96 | assert_equal 2, $set_me 97 | end 98 | 99 | it "should pass time to the general handler" do 100 | received = nil 101 | now = Time.now 102 | @manager.handler { |job, time| received = time } 103 | @manager.every(1.minute, 'myjob') 104 | @manager.tick(now) 105 | assert_equal now, received 106 | end 107 | 108 | it "should pass time to the event-specific handler" do 109 | received = nil 110 | now = Time.now 111 | @manager.every(1.minute, 'myjob') { |job, time| received = time } 112 | @manager.tick(now) 113 | assert_equal now, received 114 | end 115 | 116 | it "exceptions are trapped and logged" do 117 | @manager.handler { raise 'boom' } 118 | @manager.every(1.minute, 'myjob') 119 | 120 | mocked_logger = MiniTest::Mock.new 121 | mocked_logger.expect :error, true, [RuntimeError] 122 | @manager.configure { |c| c[:logger] = mocked_logger } 123 | @manager.tick(Time.now) 124 | mocked_logger.verify 125 | end 126 | 127 | it "exceptions still set the last timestamp to avoid spastic error loops" do 128 | @manager.handler { raise 'boom' } 129 | event = @manager.every(1.minute, 'myjob') 130 | @manager.stubs(:log_error) 131 | @manager.tick(t = Time.now) 132 | assert_equal t, event.last 133 | end 134 | 135 | it "should be configurable" do 136 | @manager.configure do |config| 137 | config[:sleep_timeout] = 200 138 | config[:logger] = "A Logger" 139 | config[:max_threads] = 10 140 | config[:thread] = true 141 | end 142 | 143 | assert_equal 200, @manager.config[:sleep_timeout] 144 | assert_equal "A Logger", @manager.config[:logger] 145 | assert_equal 10, @manager.config[:max_threads] 146 | assert_equal true, @manager.config[:thread] 147 | end 148 | 149 | it "configuration should have reasonable defaults" do 150 | assert_equal 1, @manager.config[:sleep_timeout] 151 | assert @manager.config[:logger].is_a?(Logger) 152 | assert_equal 10, @manager.config[:max_threads] 153 | assert_equal false, @manager.config[:thread] 154 | end 155 | 156 | it "should accept unnamed job" do 157 | event = @manager.every(1.minute) 158 | assert_equal 'unnamed', event.job 159 | end 160 | 161 | it "should accept options without job name" do 162 | event = @manager.every(1.minute, {}) 163 | assert_equal 'unnamed', event.job 164 | end 165 | 166 | describe ':at option' do 167 | it "once a day at 16:20" do 168 | @manager.every(1.day, 'myjob', :at => '16:20') 169 | 170 | assert_wont_run 'jan 1 2010 16:19:59' 171 | assert_will_run 'jan 1 2010 16:20:00' 172 | assert_wont_run 'jan 1 2010 16:20:01' 173 | assert_wont_run 'jan 2 2010 16:19:59' 174 | assert_will_run 'jan 2 2010 16:20:00' 175 | end 176 | 177 | it "twice a day at 16:20 and 18:10" do 178 | @manager.every(1.day, 'myjob', :at => ['16:20', '18:10']) 179 | 180 | assert_wont_run 'jan 1 2010 16:19:59' 181 | assert_will_run 'jan 1 2010 16:20:00' 182 | assert_wont_run 'jan 1 2010 16:20:01' 183 | 184 | assert_wont_run 'jan 1 2010 18:09:59' 185 | assert_will_run 'jan 1 2010 18:10:00' 186 | assert_wont_run 'jan 1 2010 18:10:01' 187 | end 188 | end 189 | 190 | describe ':tz option' do 191 | it "time zone is not set by default" do 192 | assert @manager.config[:tz].nil? 193 | end 194 | 195 | it "should be able to specify a different timezone than local" do 196 | @manager.every(1.day, 'myjob', :at => '10:00', :tz => 'UTC') 197 | 198 | assert_wont_run 'jan 1 2010 10:00:00 EST' 199 | assert_will_run 'jan 1 2010 10:00:00 UTC' 200 | end 201 | 202 | it "should be able to specify a different timezone than local for multiple times" do 203 | @manager.every(1.day, 'myjob', :at => ['10:00', '8:00'], :tz => 'UTC') 204 | 205 | assert_wont_run 'jan 1 2010 08:00:00 EST' 206 | assert_will_run 'jan 1 2010 08:00:00 UTC' 207 | assert_wont_run 'jan 1 2010 10:00:00 EST' 208 | assert_will_run 'jan 1 2010 10:00:00 UTC' 209 | end 210 | 211 | it "should be able to configure a default timezone to use for all events" do 212 | @manager.configure { |config| config[:tz] = 'UTC' } 213 | @manager.every(1.day, 'myjob', :at => '10:00') 214 | 215 | assert_wont_run 'jan 1 2010 10:00:00 EST' 216 | assert_will_run 'jan 1 2010 10:00:00 UTC' 217 | end 218 | 219 | it "should be able to override a default timezone in an event" do 220 | @manager.configure { |config| config[:tz] = 'UTC' } 221 | @manager.every(1.day, 'myjob', :at => '10:00', :tz => 'EST') 222 | 223 | assert_will_run 'jan 1 2010 10:00:00 EST' 224 | assert_wont_run 'jan 1 2010 10:00:00 UTC' 225 | end 226 | end 227 | 228 | describe ':if option' do 229 | it ":if true then always run" do 230 | @manager.every(1.second, 'myjob', :if => lambda { |_| true }) 231 | 232 | assert_will_run 'jan 1 2010 16:20:00' 233 | end 234 | 235 | it ":if false then never run" do 236 | @manager.every(1.second, 'myjob', :if => lambda { |_| false }) 237 | 238 | assert_wont_run 'jan 1 2010 16:20:00' 239 | end 240 | 241 | it ":if the first day of month" do 242 | @manager.every(1.second, 'myjob', :if => lambda { |t| t.day == 1 }) 243 | 244 | assert_will_run 'jan 1 2010 16:20:00' 245 | assert_wont_run 'jan 2 2010 16:20:00' 246 | assert_will_run 'feb 1 2010 16:20:00' 247 | end 248 | 249 | it ":if it is compared to a time with zone" do 250 | tz = 'America/Chicago' 251 | time = Time.utc(2012,5,25,10,00) 252 | @manager.every(1.second, 'myjob', tz: tz, :if => lambda { |t| 253 | ((time - 1.hour)..(time + 1.hour)).cover? t 254 | }) 255 | assert_will_run time 256 | end 257 | 258 | it ":if is not callable then raise ArgumentError" do 259 | assert_raises(ArgumentError) do 260 | @manager.every(1.second, 'myjob', :if => true) 261 | end 262 | end 263 | end 264 | 265 | describe "max_threads" do 266 | it "should warn when an event tries to generate threads more than max_threads" do 267 | logger = Logger.new(STDOUT) 268 | @manager.configure do |config| 269 | config[:max_threads] = 1 270 | config[:logger] = logger 271 | end 272 | 273 | @manager.every(1.minute, 'myjob1', :thread => true) { sleep 2 } 274 | @manager.every(1.minute, 'myjob2', :thread => true) { sleep 2 } 275 | logger.expects(:error).with("Threads exhausted; skipping myjob2") 276 | 277 | @manager.tick(Time.now) 278 | end 279 | 280 | it "should not warn when thread is managed by others" do 281 | begin 282 | t = Thread.new { sleep 5 } 283 | logger = Logger.new(StringIO.new) 284 | @manager.configure do |config| 285 | config[:max_threads] = 1 286 | config[:logger] = logger 287 | end 288 | 289 | @manager.every(1.minute, 'myjob', :thread => true) 290 | logger.expects(:error).never 291 | 292 | @manager.tick(Time.now) 293 | ensure 294 | t.kill 295 | end 296 | end 297 | end 298 | 299 | describe "callbacks" do 300 | it "should not accept unknown callback name" do 301 | assert_raises(RuntimeError, "Unsupported callback unknown_callback") do 302 | @manager.on(:unknown_callback) do 303 | true 304 | end 305 | end 306 | end 307 | 308 | it "should run before_tick callback once on tick" do 309 | counter = 0 310 | @manager.on(:before_tick) do 311 | counter += 1 312 | end 313 | @manager.tick 314 | assert_equal 1, counter 315 | end 316 | 317 | it "should not run events if before_tick returns false" do 318 | @manager.on(:before_tick) do 319 | false 320 | end 321 | @manager.every(1.second, 'myjob') { raise "should not run" } 322 | @manager.tick 323 | end 324 | 325 | it "should run before_run twice if two events are registered" do 326 | counter = 0 327 | @manager.on(:before_run) do 328 | counter += 1 329 | end 330 | @manager.every(1.second, 'myjob') 331 | @manager.every(1.second, 'myjob2') 332 | @manager.tick 333 | assert_equal 2, counter 334 | end 335 | 336 | it "should run even jobs only" do 337 | counter = 0 338 | ran = false 339 | @manager.on(:before_run) do 340 | counter += 1 341 | counter % 2 == 0 342 | end 343 | @manager.every(1.second, 'myjob') { raise "should not ran" } 344 | @manager.every(1.second, 'myjob2') { ran = true } 345 | @manager.tick 346 | assert ran 347 | end 348 | 349 | it "should run after_run callback for each event" do 350 | counter = 0 351 | @manager.on(:after_run) do 352 | counter += 1 353 | end 354 | @manager.every(1.second, 'myjob') 355 | @manager.every(1.second, 'myjob2') 356 | @manager.tick 357 | assert_equal 2, counter 358 | end 359 | 360 | it "should run after_tick callback once" do 361 | counter = 0 362 | @manager.on(:after_tick) do 363 | counter += 1 364 | end 365 | @manager.tick 366 | assert_equal 1, counter 367 | end 368 | end 369 | 370 | describe 'error_handler' do 371 | before do 372 | @errors = [] 373 | @manager.error_handler do |e| 374 | @errors << e 375 | end 376 | 377 | # block error log 378 | @string_io = StringIO.new 379 | @manager.configure do |config| 380 | config[:logger] = Logger.new(@string_io) 381 | end 382 | @manager.every(1.second, 'myjob') { raise 'it error' } 383 | end 384 | 385 | it 'registered error_handler handles error from event' do 386 | @manager.tick 387 | assert_equal ['it error'], @errors.map(&:message) 388 | end 389 | 390 | it 'error is notified to logger and handler' do 391 | @manager.tick 392 | assert @string_io.string.include?('it error') 393 | end 394 | 395 | it 'error in handler will NOT be suppressed' do 396 | @manager.error_handler do |e| 397 | raise e.message + ' re-raised' 398 | end 399 | assert_raises(RuntimeError, 'it error re-raised') do 400 | @manager.tick 401 | end 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /test/database_events/synchronizer_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require 'mocha/setup' 3 | require 'time' 4 | require 'active_support/time' 5 | 6 | require_relative '../../lib/clockwork' 7 | require_relative '../../lib/clockwork/database_events' 8 | require_relative 'test_helpers' 9 | 10 | describe Clockwork::DatabaseEvents::Synchronizer do 11 | before do 12 | @now = Time.now 13 | 14 | Clockwork.manager = @manager = Clockwork::DatabaseEvents::Manager.new 15 | class << @manager 16 | def log(msg); end # silence log output 17 | end 18 | end 19 | 20 | after do 21 | Clockwork.clear! 22 | DatabaseEventModel.delete_all 23 | DatabaseEventModel2.delete_all 24 | DatabaseEventModelWithIf.delete_all 25 | end 26 | 27 | describe "setup" do 28 | before do 29 | @subject = Clockwork::DatabaseEvents::Synchronizer 30 | end 31 | 32 | describe "arguments" do 33 | it 'raises argument error if model is not set' do 34 | error = assert_raises KeyError do 35 | @subject.setup(every: 1.minute) {} 36 | end 37 | assert_equal error.message, ":model must be set to the model class" 38 | end 39 | 40 | it 'raises argument error if every is not set' do 41 | error = assert_raises KeyError do 42 | @subject.setup(model: DatabaseEventModel) {} 43 | end 44 | assert_equal error.message, ":every must be set to the database sync frequency" 45 | end 46 | end 47 | 48 | describe "when database reload frequency is greater than model frequency period" do 49 | before do 50 | @events_run = [] 51 | @sync_frequency = 1.minute 52 | end 53 | 54 | it 'fetches and registers event from database' do 55 | DatabaseEventModel.create(:frequency => 10) 56 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 57 | 58 | tick_at(@now, :and_every_second_for => 1.second) 59 | 60 | assert_equal ["DatabaseEventModel:1"], @events_run 61 | end 62 | 63 | it 'fetches and registers multiple events from database' do 64 | DatabaseEventModel.create(:frequency => 10) 65 | DatabaseEventModel.create(:frequency => 10) 66 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 67 | 68 | tick_at(@now, :and_every_second_for => 1.second) 69 | 70 | assert_equal ["DatabaseEventModel:1", "DatabaseEventModel:2"], @events_run 71 | end 72 | 73 | it 'does not run event again before frequency specified in database' do 74 | model = DatabaseEventModel.create(:frequency => 10) 75 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 76 | 77 | tick_at(@now, :and_every_second_for => model.frequency - 1.second) 78 | assert_equal 1, @events_run.length 79 | end 80 | 81 | it 'runs event repeatedly with frequency specified in database' do 82 | model = DatabaseEventModel.create(:frequency => 10) 83 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 84 | 85 | tick_at(@now, :and_every_second_for => (2 * model.frequency) + 1.second) 86 | 87 | assert_equal 3, @events_run.length 88 | end 89 | 90 | it 'runs reloaded events from database repeatedly' do 91 | model = DatabaseEventModel.create(:frequency => 10) 92 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 93 | 94 | tick_at(@now, :and_every_second_for => @sync_frequency - 1) 95 | model.update(:name => "DatabaseEventModel:1:Reloaded") 96 | tick_at(@now + @sync_frequency, :and_every_second_for => model.frequency * 2) 97 | 98 | assert_equal ["DatabaseEventModel:1:Reloaded", "DatabaseEventModel:1:Reloaded"], @events_run[-2..-1] 99 | end 100 | 101 | it 'updates modified event frequency with event reloading' do 102 | model = DatabaseEventModel.create(:frequency => 10) 103 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 104 | 105 | tick_at(@now, :and_every_second_for => @sync_frequency - 1.second) 106 | model.update(:frequency => 5) 107 | tick_at(@now + @sync_frequency, :and_every_second_for => 6.seconds) 108 | 109 | # model runs at: 1, 11, 21, 31, 41, 51 (6 runs) 110 | # database sync happens at: 60 111 | # modified model runs at: 61 (next tick after reload) and then 66 (2 runs) 112 | assert_equal 8, @events_run.length 113 | end 114 | 115 | it 'stoped running deleted events from database' do 116 | model = DatabaseEventModel.create(:frequency => 10) 117 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 118 | 119 | tick_at(@now, :and_every_second_for => (@sync_frequency - 1.second)) 120 | before = @events_run.dup 121 | model.delete! 122 | tick_at(@now + @sync_frequency, :and_every_second_for => @sync_frequency) 123 | after = @events_run 124 | 125 | assert_equal before, after 126 | end 127 | 128 | it 'updates event name with new name' do 129 | model = DatabaseEventModel.create(:frequency => 10.seconds) 130 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 131 | 132 | tick_at @now, :and_every_second_for => @sync_frequency - 1.second 133 | @events_run.clear 134 | model.update(:name => "DatabaseEventModel:1_modified") 135 | tick_at @now + @sync_frequency, :and_every_second_for => (model.frequency * 2) 136 | 137 | assert_equal ["DatabaseEventModel:1_modified", "DatabaseEventModel:1_modified"], @events_run 138 | end 139 | 140 | it 'updates event frequency with new frequency' do 141 | model = DatabaseEventModel.create(:frequency => 10) 142 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 143 | 144 | tick_at @now, :and_every_second_for => @sync_frequency - 1.second 145 | @events_run.clear 146 | model.update(:frequency => 30) 147 | tick_at @now + @sync_frequency, :and_every_second_for => @sync_frequency - 1.seconds 148 | 149 | assert_equal 2, @events_run.length 150 | end 151 | 152 | it 'updates event at with new at' do 153 | model = DatabaseEventModel.create(:frequency => 1.day, :at => '10:30') 154 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 155 | 156 | assert_will_run 'jan 1 2010 10:30:00' 157 | assert_wont_run 'jan 1 2010 09:30:00' 158 | 159 | model.update(:at => '09:30') 160 | tick_at @now, :and_every_second_for => @sync_frequency + 1.second 161 | 162 | assert_will_run 'jan 1 2010 09:30:00' 163 | assert_wont_run 'jan 1 2010 10:30:00' 164 | end 165 | 166 | describe "when #name is defined" do 167 | it 'runs daily event with at from databse only once' do 168 | DatabaseEventModel.create(:frequency => 1.day, :at => next_minute(@now).strftime('%H:%M')) 169 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 170 | 171 | # tick from now, though specified :at time 172 | tick_at(@now, :and_every_second_for => (2 * @sync_frequency) + 1.second) 173 | 174 | assert_equal 1, @events_run.length 175 | end 176 | end 177 | 178 | describe "when #name is not defined" do 179 | it 'runs daily event with at from databse only once' do 180 | DatabaseEventModelWithoutName.create(:frequency => 1.day, :at => next_minute(next_minute(@now)).strftime('%H:%M')) 181 | setup_sync(model: DatabaseEventModelWithoutName, :every => @sync_frequency, :events_run => @events_run) 182 | 183 | # tick from now, though specified :at time 184 | tick_at(@now, :and_every_second_for => (2 * @sync_frequency) + 1.second) 185 | 186 | assert_equal 1, @events_run.length 187 | end 188 | end 189 | 190 | it 'creates multiple event ats with comma separated at string' do 191 | DatabaseEventModel.create(:frequency => 1.day, :at => '16:20, 18:10') 192 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 193 | 194 | tick_at @now, :and_every_second_for => 1.second 195 | 196 | assert_wont_run 'jan 1 2010 16:19:59' 197 | assert_will_run 'jan 1 2010 16:20:00' 198 | assert_wont_run 'jan 1 2010 16:20:01' 199 | 200 | assert_wont_run 'jan 1 2010 18:09:59' 201 | assert_will_run 'jan 1 2010 18:10:00' 202 | assert_wont_run 'jan 1 2010 18:10:01' 203 | end 204 | 205 | it 'allows syncing multiple database models' do 206 | DatabaseEventModel.create(:frequency => 10) 207 | setup_sync(model: DatabaseEventModel, :every => @sync_frequency, :events_run => @events_run) 208 | 209 | DatabaseEventModel2.create(:frequency => 10) 210 | setup_sync(model: DatabaseEventModel2, :every => @sync_frequency, :events_run => @events_run) 211 | 212 | tick_at(@now, :and_every_second_for => 1.second) 213 | 214 | assert_equal ["DatabaseEventModel:1", "DatabaseEventModel2:1"], @events_run 215 | end 216 | end 217 | 218 | describe "when database reload frequency is less than model frequency period" do 219 | before do 220 | @events_run = [] 221 | end 222 | 223 | it 'runs event only once within the model frequency period' do 224 | DatabaseEventModel.create(:frequency => 5.minutes) 225 | setup_sync(model: DatabaseEventModel, :every => 1.minute, :events_run => @events_run) 226 | 227 | tick_at(@now, :and_every_second_for => 5.minutes) 228 | 229 | assert_equal 1, @events_run.length 230 | end 231 | end 232 | 233 | describe "with database event :at set to empty string" do 234 | before do 235 | @events_run = [] 236 | 237 | DatabaseEventModel.create(:frequency => 10) 238 | setup_sync(model: DatabaseEventModel, :every => 1.minute, :events_run => @events_run) 239 | end 240 | 241 | it 'does not raise an error' do 242 | begin 243 | tick_at(Time.now, :and_every_second_for => 10.seconds) 244 | rescue => e 245 | assert false, "Raised an error: #{e.message}" 246 | end 247 | end 248 | 249 | it 'runs the event' do 250 | begin 251 | tick_at(Time.now, :and_every_second_for => 10.seconds) 252 | rescue 253 | end 254 | assert_equal 1, @events_run.length 255 | end 256 | end 257 | 258 | describe "with model that responds to `if?`" do 259 | 260 | before do 261 | @events_run = [] 262 | end 263 | 264 | describe "when model.if? is true" do 265 | it 'runs' do 266 | DatabaseEventModelWithIf.create(:if_state => true, :frequency => 10) 267 | setup_sync(model: DatabaseEventModelWithIf, :every => 1.minute, :events_run => @events_run) 268 | 269 | tick_at(@now, :and_every_second_for => 9.seconds) 270 | 271 | assert_equal 1, @events_run.length 272 | end 273 | end 274 | 275 | describe "when model.if? is false" do 276 | it 'does not run' do 277 | DatabaseEventModelWithIf.create(:if_state => false, :frequency => 10, :name => 'model with if?') 278 | setup_sync(model: DatabaseEventModelWithIf, :every => 1.minute, :events_run => @events_run) 279 | 280 | tick_at(@now, :and_every_second_for => 1.minute) 281 | 282 | # require 'byebug' 283 | # byebug if events_run.length > 0 284 | assert_equal 0, @events_run.length 285 | end 286 | end 287 | end 288 | 289 | describe "with task that responds to `tz`" do 290 | before do 291 | @events_run = [] 292 | @utc_time_now = Time.now.utc 293 | 294 | DatabaseEventModel.create(:frequency => 1.days, :at => @utc_time_now.strftime('%H:%M'), :tz => 'America/Montreal') 295 | setup_sync(model: DatabaseEventModel, :every => 1.minute, :events_run => @events_run) 296 | end 297 | 298 | it 'does not raise an error' do 299 | begin 300 | tick_at(@utc_time_now, :and_every_second_for => 10.seconds) 301 | rescue => e 302 | assert false, "Raised an error: #{e.message}" 303 | end 304 | end 305 | 306 | it 'does not run the event based on UTC' do 307 | begin 308 | tick_at(@utc_time_now, :and_every_second_for => 3.hours) 309 | rescue 310 | end 311 | assert_equal 0, @events_run.length 312 | end 313 | 314 | it 'runs the event based on America/Montreal tz' do 315 | begin 316 | tick_at(@utc_time_now, :and_every_second_for => 5.hours) 317 | rescue 318 | end 319 | assert_equal 1, @events_run.length 320 | end 321 | end 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Clockwork - a clock process to replace cron [![Build Status](https://api.travis-ci.org/Rykian/clockwork.png?branch=master)](https://travis-ci.org/Rykian/clockwork) 2 | =========================================== 3 | 4 | Cron is non-ideal for running scheduled application tasks, especially in an app 5 | deployed to multiple machines. [More details.](http://adam.herokuapp.com/past/2010/4/13/rethinking_cron/) 6 | 7 | Clockwork is a cron replacement. It runs as a lightweight, long-running Ruby 8 | process which sits alongside your web processes (Mongrel/Thin) and your worker 9 | processes (DJ/Resque/Minion/Stalker) to schedule recurring work at particular 10 | times or dates. For example, refreshing feeds on an hourly basis, or send 11 | reminder emails on a nightly basis, or generating invoices once a month on the 12 | 1st. 13 | 14 | Quickstart 15 | ---------- 16 | 17 | Create clock.rb: 18 | 19 | ```ruby 20 | require 'clockwork' 21 | require 'active_support/time' # Allow numeric durations (eg: 1.minutes) 22 | 23 | module Clockwork 24 | handler do |job| 25 | puts "Running #{job}" 26 | end 27 | 28 | # handler receives the time when job is prepared to run in the 2nd argument 29 | # handler do |job, time| 30 | # puts "Running #{job}, at #{time}" 31 | # end 32 | 33 | every(10.seconds, 'frequent.job') 34 | every(3.minutes, 'less.frequent.job') 35 | every(1.hour, 'hourly.job') 36 | 37 | every(1.day, 'midnight.job', :at => '00:00') 38 | end 39 | ``` 40 | 41 | Run it with the clockwork executable: 42 | 43 | ``` 44 | $ clockwork clock.rb 45 | Starting clock for 4 events: [ frequent.job less.frequent.job hourly.job midnight.job ] 46 | Triggering frequent.job 47 | ``` 48 | 49 | If you need to load your entire environment for your jobs, simply add: 50 | 51 | ```ruby 52 | require './config/boot' 53 | require './config/environment' 54 | ``` 55 | 56 | under the `require 'clockwork'` declaration. 57 | 58 | Quickstart for Heroku 59 | --------------------- 60 | 61 | Clockwork fits well with heroku's cedar stack. 62 | 63 | Consider using [clockwork-init.sh](https://gist.github.com/tomykaira/1312172) to create 64 | a new project for Heroku. 65 | 66 | Use with queueing 67 | ----------------- 68 | 69 | The clock process only makes sense as a place to schedule work to be done, not 70 | to do the work. It avoids locking by running as a single process, but this 71 | makes it impossible to parallelize. For doing the work, you should be using a 72 | job queueing system, such as 73 | [Delayed Job](http://www.therailsway.com/2009/7/22/do-it-later-with-delayed-job), 74 | [Beanstalk/Stalker](http://adam.herokuapp.com/past/2010/4/24/beanstalk_a_simple_and_fast_queueing_backend/), 75 | [RabbitMQ/Minion](http://adam.herokuapp.com/past/2009/9/28/background_jobs_with_rabbitmq_and_minion/), 76 | [Resque](http://github.com/blog/542-introducing-resque), or 77 | [Sidekiq](https://github.com/mperham/sidekiq). This design allows a 78 | simple clock process with no locks, but also offers near infinite horizontal 79 | scalability. 80 | 81 | For example, if you're using Beanstalk/Stalker: 82 | 83 | ```ruby 84 | require 'stalker' 85 | require 'active_support/time' 86 | 87 | module Clockwork 88 | handler { |job| Stalker.enqueue(job) } 89 | 90 | every(1.hour, 'feeds.refresh') 91 | every(1.day, 'reminders.send', :at => '01:30') 92 | end 93 | ``` 94 | 95 | Using a queueing system which doesn't require that your full application be 96 | loaded is preferable, because the clock process can keep a tiny memory 97 | footprint. If you're using DJ or Resque, however, you can go ahead and load 98 | your full application environment, and use per-event blocks to call DJ or Resque 99 | enqueue methods. For example, with DJ/Rails: 100 | 101 | ```ruby 102 | require 'config/boot' 103 | require 'config/environment' 104 | 105 | every(1.hour, 'feeds.refresh') { Feed.send_later(:refresh) } 106 | every(1.day, 'reminders.send', :at => '01:30') { Reminder.send_later(:send_reminders) } 107 | ``` 108 | 109 | Use with database events 110 | ----------------------- 111 | 112 | In addition to managing static events in your `clock.rb`, you can configure clockwork to synchronise with dynamic events from a database. Like static events, these database-backed events say when they should be run, and how frequently; the difference being that if you change those settings in the database, they will be reflected in clockwork. 113 | 114 | To keep the database events in sync with clockwork, a special manager class `DatabaseEvents::Manager` is used. You tell it to sync a database-backed model using the `sync_database_events` method, and then, at the frequency you specify, it will fetch all the events from the database, and ensure clockwork is using the latest settings. 115 | 116 | ### Example `clock.rb` file 117 | 118 | Here we're using an `ActiveRecord` model called `ClockworkDatabaseEvent` to store events in the database: 119 | 120 | ```ruby 121 | require 'clockwork' 122 | require 'clockwork/database_events' 123 | require_relative './config/boot' 124 | require_relative './config/environment' 125 | 126 | module Clockwork 127 | 128 | # required to enable database syncing support 129 | Clockwork.manager = DatabaseEvents::Manager.new 130 | 131 | sync_database_events model: ClockworkDatabaseEvent, every: 1.minute do |model_instance| 132 | 133 | # do some work e.g... 134 | 135 | # running a DelayedJob task, where #some_action is a method 136 | # you've defined on the model, which does the work you need 137 | model_instance.delay.some_action 138 | 139 | # performing some work with Sidekiq 140 | YourSidekiqWorkerClass.perform_async 141 | end 142 | 143 | [other events if you have] 144 | 145 | end 146 | ``` 147 | 148 | This tells clockwork to fetch all `ClockworkDatabaseEvent` instances from the database, and create an internal clockwork event for each one. Each clockwork event will be configured based on the instance's `frequency` and, optionally, its `at`, `if?`, `ignored_attributes`, `name`, and, `tz` methods. The code above also says to reload the events from the database every `1.minute`; we need pick up any changes in the database frequently (choose a sensible reload frequency by changing the `every:` option). 149 | 150 | When one of the events is ready to be run (based on it's `frequency`, and possible `at`, `if?`, `ignored attributes`, and `tz` methods), clockwork arranges for the block passed to `sync_database_events` to be run. The above example shows how you could use either DelayedJob or Sidekiq to kick off a worker job. This approach is good because the ideal is to use clockwork as a simple scheduler, and avoid making it carry out any long-running tasks. 151 | 152 | ### Your Model Classes 153 | 154 | `ActiveRecord` models are a perfect candidate for the model class. Having said that, the only requirements are: 155 | 156 | 1. the class responds to `all` returning an array of instances from the database 157 | 158 | 2. the instances returned respond to: 159 | - `id` returning a unique identifier (this is needed to track changes to event settings) 160 | - `frequency` returning the how frequently (in seconds) the database event should be run 161 | 162 | - `attributes` returning a hash of [attribute name] => [attribute value] values (or really anything that we can use store on registering the event, and then compare again to see if the state has changed later) 163 | 164 | - `at` *(optional)* return any acceptable clockwork `:at` string 165 | 166 | - `name` *(optional)* returning the name for the event (used to identify it in the Clockwork output) 167 | 168 | - `if?`*(optional)* returning either true or false, depending on whether the database event should run at the given time (this method will be passed the time as a parameter, much like the standard clockwork `:if`) 169 | 170 | - `ignored_attributes` *(optional)* returning an array of model attributes (as symbols) to ignore when determining whether the database event has been modified since our last run 171 | 172 | - `tz` *(optional)* returning the timezone to use (default is the local timezone) 173 | 174 | #### Example Setup 175 | 176 | Here's an example of one way of setting up your ActiveRecord models: 177 | 178 | ```ruby 179 | # db/migrate/20140302220659_create_frequency_periods.rb 180 | class CreateFrequencyPeriods < ActiveRecord::Migration 181 | def change 182 | create_table :frequency_periods do |t| 183 | t.string :name 184 | 185 | t.timestamps 186 | end 187 | end 188 | end 189 | 190 | # 20140302221102_create_clockwork_database_events.rb 191 | class CreateClockworkDatabaseEvents < ActiveRecord::Migration 192 | def change 193 | create_table :clockwork_database_events do |t| 194 | t.integer :frequency_quantity 195 | t.references :frequency_period 196 | t.string :at 197 | 198 | t.timestamps 199 | end 200 | add_index :clockwork_database_events, :frequency_period_id 201 | end 202 | end 203 | 204 | # app/models/clockwork_database_event.rb 205 | class ClockworkDatabaseEvent < ActiveRecord::Base 206 | belongs_to :frequency_period 207 | attr_accessible :frequency_quantity, :frequency_period_id, :at 208 | 209 | # Used by clockwork to schedule how frequently this event should be run 210 | # Should be the intended number of seconds between executions 211 | def frequency 212 | frequency_quantity.send(frequency_period.name.pluralize) 213 | end 214 | end 215 | 216 | # app/models/frequency_period.rb 217 | class FrequencyPeriod < ActiveRecord::Base 218 | attr_accessible :name 219 | end 220 | 221 | # db/seeds.rb 222 | ... 223 | # creating the FrequencyPeriods 224 | [:second, :minute, :hour, :day, :week, :month].each do |period| 225 | FrequencyPeriod.create(name: period) 226 | end 227 | ... 228 | ``` 229 | 230 | #### Example use of `if?` 231 | 232 | Database events support the ability to run events if certain conditions are met. This can be used to only run events on a given day, week, or month, or really any criteria you could conceive. Best of all, these criteria e.g. which day to 233 | run it on can be attributes on your Model, and therefore change dynamically as you change the Model in the database. 234 | 235 | So for example, if you had a Model that had a `day` and `month` integer attribute, you could specify that the Database event should only run on a particular day of a particular month as follows: 236 | 237 | ```ruby 238 | # app/models/clockwork_database_event.rb 239 | class ClockworkDatabaseEvent < ActiveRecord::Base 240 | 241 | ... 242 | 243 | def if?(time) 244 | time.day == day && time.month == month 245 | end 246 | 247 | ... 248 | end 249 | ``` 250 | 251 | #### Example use of `ignored_attributes` 252 | 253 | Clockwork compares all attributes of the model between runs to determine if the model has changed, and if it has, it runs the event if all other conditions are met. 254 | 255 | However, in certain cases, you may want to store additional attributes in your model that you don't want to affect whether a database event is executed prior to its next interval. 256 | 257 | So for example, you may update an attribute of your model named `last_scheduled_at` on each run to track the last time it was successfully scheduled. You can tell Clockwork to ignore that attribute in its comparison as follows: 258 | 259 | ```ruby 260 | # app/models/clockwork_database_event.rb 261 | class ClockworkDatabaseEvent < ActiveRecord::Base 262 | 263 | ... 264 | 265 | def ignored_attributes 266 | [ :last_scheduled_at ] 267 | end 268 | 269 | ... 270 | end 271 | ``` 272 | 273 | 274 | Event Parameters 275 | ---------- 276 | 277 | ### :at 278 | 279 | `:at` parameter specifies when to trigger the event: 280 | 281 | #### Valid formats: 282 | 283 | HH:MM 284 | H:MM 285 | **:MM 286 | HH:** 287 | (Mon|mon|Monday|monday) HH:MM 288 | 289 | #### Examples 290 | 291 | The simplest example: 292 | 293 | ```ruby 294 | every(1.day, 'reminders.send', :at => '01:30') 295 | ``` 296 | 297 | You can omit the leading 0 of the hour: 298 | 299 | ```ruby 300 | every(1.day, 'reminders.send', :at => '1:30') 301 | ``` 302 | 303 | Wildcards for hour and minute are supported: 304 | 305 | ```ruby 306 | every(1.hour, 'reminders.send', :at => '**:30') 307 | every(10.seconds, 'frequent.job', :at => '9:**') 308 | ``` 309 | 310 | You can set more than one timing: 311 | 312 | ```ruby 313 | every(1.day, 'reminders.send', :at => ['12:00', '18:00']) 314 | # send reminders at noon and evening 315 | ``` 316 | 317 | You can specify the day of week to run: 318 | 319 | ```ruby 320 | every(1.week, 'myjob', :at => 'Monday 16:20') 321 | ``` 322 | 323 | If another task is already running at the specified time, clockwork will skip execution of the task with the `:at` option. 324 | If this is a problem, please use the `:thread` option to prevent the long running task from blocking clockwork's scheduler. 325 | 326 | ### :tz 327 | 328 | `:tz` parameter lets you specify a timezone (default is the local timezone): 329 | 330 | ```ruby 331 | every(1.day, 'reminders.send', :at => '00:00', :tz => 'UTC') 332 | # Runs the job each day at midnight, UTC. 333 | # The value for :tz can be anything supported by [TZInfo](https://github.com/tzinfo/tzinfo) 334 | # Using the 'tzinfo' gem, run TZInfo::Timezone.all_identifiers to get a list of acceptable identifiers. 335 | ``` 336 | 337 | ### :if 338 | 339 | `:if` parameter is invoked every time the task is ready to run, and run if the 340 | return value is true. 341 | 342 | Run on every first day of month. 343 | 344 | ```ruby 345 | Clockwork.every(1.day, 'myjob', :if => lambda { |t| t.day == 1 }) 346 | ``` 347 | 348 | The argument is an instance of `ActiveSupport::TimeWithZone` if the `:tz` option is set. Otherwise, it's an instance of `Time`. 349 | 350 | This argument cannot be omitted. Please use _ as placeholder if not needed. 351 | 352 | ```ruby 353 | Clockwork.every(1.second, 'myjob', :if => lambda { |_| true }) 354 | ``` 355 | 356 | ### :thread 357 | 358 | By default, clockwork runs in a single-process and single-thread. 359 | If an event handler takes a long time, the main routine of clockwork is blocked until it ends. 360 | Clockwork does not misbehave, but the next event is blocked, and runs when the process is returned to the clockwork routine. 361 | 362 | The `:thread` option is to avoid blocking. An event with `thread: true` runs in a different thread. 363 | 364 | ```ruby 365 | Clockwork.every(1.day, 'run.me.in.new.thread', :thread => true) 366 | ``` 367 | 368 | If a job is long-running or IO-intensive, this option helps keep the clock precise. 369 | 370 | ### :skip_first_run 371 | 372 | Normally, a clockwork process that is defined to run in a specified period will run at startup. 373 | This is sometimes undesired behaviour, if the action being run relies on other processes booting which may be slower than clock. 374 | To avoid this problem, `:skip_first_run` can be used. 375 | 376 | ```ruby 377 | Clockwork.every(5.minutes, 'myjob', :skip_first_run => true) 378 | ``` 379 | 380 | The above job will not run at initial boot, and instead run every 5 minutes after boot. 381 | 382 | 383 | Configuration 384 | ----------------------- 385 | 386 | Clockwork exposes a couple of configuration options: 387 | 388 | ### :logger 389 | 390 | By default Clockwork logs to `STDOUT`. In case you prefer your 391 | own logger implementation you have to specify the `logger` configuration option. See example below. 392 | 393 | ### :sleep_timeout 394 | 395 | Clockwork wakes up once a second and performs its duties. To change the number of seconds Clockwork 396 | sleeps, set the `sleep_timeout` configuration option as shown below in the example. 397 | 398 | From 1.1.0, Clockwork does not accept `sleep_timeout` less than 1 seconds. 399 | 400 | ### :tz 401 | 402 | This is the default timezone to use for all events. When not specified this defaults to the local 403 | timezone. Specifying :tz in the parameters for an event overrides anything set here. 404 | 405 | ### :max_threads 406 | 407 | Clockwork runs handlers in threads. If it exceeds `max_threads`, it will warn you (log an error) about missing 408 | jobs. 409 | 410 | 411 | ### :thread 412 | 413 | Boolean true or false. Default is false. If set to true, every event will be run in its own thread. Can be overridden on a per event basis (see the ```:thread``` option in the Event Parameters section above) 414 | 415 | ### Configuration example 416 | 417 | ```ruby 418 | module Clockwork 419 | configure do |config| 420 | config[:sleep_timeout] = 5 421 | config[:logger] = Logger.new(log_file_path) 422 | config[:tz] = 'EST' 423 | config[:max_threads] = 15 424 | config[:thread] = true 425 | end 426 | end 427 | ``` 428 | 429 | ### error_handler 430 | 431 | You can add error_handler to define your own logging or error rescue. 432 | 433 | ```ruby 434 | module Clockwork 435 | error_handler do |error| 436 | Airbrake.notify_or_ignore(error) 437 | end 438 | end 439 | ``` 440 | 441 | Current specifications are as follows. 442 | 443 | - defining error_handler does not disable original logging 444 | - errors from error_handler itself are not rescued, and stop clockwork 445 | 446 | Any suggestion about these specifications is welcome. 447 | 448 | Old style 449 | --------- 450 | 451 | `include Clockwork` is old style. 452 | The old style is still supported, though not recommended, because it pollutes the global namespace. 453 | 454 | 455 | 456 | Anatomy of a clock file 457 | ----------------------- 458 | 459 | clock.rb is standard Ruby. Since we include the Clockwork module (the 460 | clockwork executable does this automatically, or you can do it explicitly), this 461 | exposes a small DSL to define the handler for events, and then the events themselves. 462 | 463 | The handler typically looks like this: 464 | 465 | ```ruby 466 | handler { |job| enqueue_your_job(job) } 467 | ``` 468 | 469 | This block will be invoked every time an event is triggered, with the job name 470 | passed in. In most cases, you should be able to pass the job name directly 471 | through to your queueing system. 472 | 473 | The second part of the file, which lists the events, roughly resembles a crontab: 474 | 475 | ```ruby 476 | every(5.minutes, 'thing.do') 477 | every(1.hour, 'otherthing.do') 478 | ``` 479 | 480 | In the first line of this example, an event will be triggered once every five 481 | minutes, passing the job name 'thing.do' into the handler. The handler shown 482 | above would thus call enqueue_your_job('thing.do'). 483 | 484 | You can also pass a custom block to the handler, for job queueing systems that 485 | rely on classes rather than job names (i.e. DJ and Resque). In this case, you 486 | need not define a general event handler, and instead provide one with each 487 | event: 488 | 489 | ```ruby 490 | every(5.minutes, 'thing.do') { Thing.send_later(:do) } 491 | ``` 492 | 493 | If you provide a custom handler for the block, the job name is used only for 494 | logging. 495 | 496 | You can also use blocks to do more complex checks: 497 | 498 | ```ruby 499 | every(1.day, 'check.leap.year') do 500 | Stalker.enqueue('leap.year.party') if Date.leap?(Time.now.year) 501 | end 502 | ``` 503 | 504 | In addition, Clockwork also supports `:before_tick`, `:after_tick`, `:before_run`, and `:after_run` callbacks. 505 | All callbacks are optional. The `tick` callbacks run every tick (a tick being whatever your `:sleep_timeout` 506 | is set to, default is 1 second): 507 | 508 | ```ruby 509 | on(:before_tick) do 510 | puts "tick" 511 | end 512 | 513 | on(:after_tick) do 514 | puts "tock" 515 | end 516 | 517 | on(:before_run) do |event, t| 518 | puts "job_started: #{event}" 519 | end 520 | 521 | on(:after_run) do |event, t| 522 | puts "job_finished: #{event}" 523 | end 524 | ``` 525 | 526 | Finally, you can use tasks synchronised from a database as described in detail above: 527 | 528 | ```ruby 529 | sync_database_events model: MyEvent, every: 1.minute do |instance_job_name| 530 | # what to do with each instance 531 | end 532 | ``` 533 | 534 | You can use multiple `sync_database_events` if you wish, so long as you use different model classes for each (ActiveRecord Single Table Inheritance could be a good idea if you're doing this). 535 | 536 | In production 537 | ------------- 538 | 539 | Only one clock process should ever be running across your whole application 540 | deployment. For example, if your app is running on three VPS machines (two app 541 | servers and one database), your app machines might have the following process 542 | topography: 543 | 544 | * App server 1: 3 web (thin start), 3 workers (rake jobs:work), 1 clock (clockwork clock.rb) 545 | * App server 2: 3 web (thin start), 3 workers (rake jobs:work) 546 | 547 | You should use [Monit](http://mmonit.com/monit/), [God](https://github.com/mojombo/god), [Upstart](http://upstart.ubuntu.com/), or [Inittab](http://www.tldp.org/LDP/sag/html/config-init.html) to keep your clock process 548 | running the same way you keep your web and workers running. 549 | 550 | Daemonization 551 | ------------- 552 | 553 | Thanks to @fddayan, `clockworkd` executes clockwork script as a daemon. 554 | 555 | You will need the [daemons gem](https://github.com/ghazel/daemons) to use `clockworkd`. It is not automatically installed, please install by yourself. 556 | 557 | Then, 558 | 559 | ``` 560 | clockworkd -c YOUR_CLOCK.rb start 561 | ``` 562 | 563 | For more details, you can run `clockworkd -h`. 564 | 565 | Integration Testing 566 | ------------------- 567 | 568 | You could take a look at: 569 | * [clockwork-mocks](https://github.com/dpoetzsch/clockwork-mocks) that helps with running scheduled tasks during integration testing. 570 | * [clockwork-test](https://github.com/kevin-j-m/clockwork-test) which ensures that tasks are triggered at the right time 571 | 572 | Issues and Pull requests 573 | ------------------------ 574 | 575 | If you find a bug, please create an issue - [Issues · Rykian/clockwork](https://github.com/Rykian/clockwork/issues). 576 | 577 | For a bug fix or a feature request, please send a pull-request. 578 | Do not forget to add tests to show how your feature works, or what bug is fixed. 579 | All existing tests and new tests must pass (TravisCI is watching). 580 | 581 | We want to provide simple and customizable core, so superficial changes will not be merged (e.g. supporting new event registration style). 582 | In most cases, directly operating `Manager` realizes an idea, without touching the core. 583 | If you discover a new way to use Clockwork, please create a gist page or an article on your website, then add it to the following "Use cases" section. 584 | This tool is already used in various environment, so backward-incompatible requests will be mostly rejected. 585 | 586 | Use cases 587 | --------- 588 | 589 | Feel free to add your idea or experience and send a pull-request. 590 | 591 | - Sending errors to Airbrake 592 | - Read events from a database 593 | 594 | Meta 595 | ---- 596 | 597 | Created by Adam Wiggins 598 | 599 | Inspired by [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler) and [resque-scheduler](https://github.com/bvandenbos/resque-scheduler) 600 | 601 | Design assistance from Peter van Hardenberg and Matthew Soldo 602 | 603 | Patches contributed by Mark McGranaghan and Lukáš Konarovský 604 | 605 | Released under the MIT License: http://www.opensource.org/licenses/mit-license.php 606 | 607 | https://github.com/Rykian/clockwork 608 | --------------------------------------------------------------------------------