Compare commits

..

33 Commits

Author SHA1 Message Date
Dzmitry Plashchynski
186fe43fc0 Bump version 0.6.1 2015-03-07 01:39:32 +02:00
Dzmitry Plashchynski
0351079961 Handle situation when Job#next is in the past 2015-03-07 01:38:50 +02:00
Dzmitry Plashchynski
2b53dc7ea1 Error when no jobs 2015-03-07 01:35:45 +02:00
Dzmitry Plashchynski
b649594084 Fix specs 2015-03-07 01:29:16 +02:00
Dzmitry Plashchynski
9138826324 Bump 0.6.0 2015-03-07 01:24:26 +02:00
Dzmitry Plashchynski
3a3620d55c Fix migration 2015-03-07 00:52:50 +02:00
Dzmitry Plashchynski
fe24b435b3 Save job on every perform 2015-03-06 23:57:55 +02:00
Dzmitry Plashchynski
c8a4189fd4 Call Job#load on every Scheduler#add_job call 2015-03-06 23:48:34 +02:00
Dzmitry Plashchynski
78b1d8d6e1 Add Job#load to load info from DB 2015-03-06 23:44:30 +02:00
Dzmitry Plashchynski
c54f52a71d Add Job#save to save job info to DB 2015-03-06 23:41:03 +02:00
Dzmitry Plashchynski
d0b35aaa6e Validate uniqueness of CronoJob job_id 2015-03-06 23:27:02 +02:00
Dzmitry Plashchynski
05113b57ee Add an ActiveRecord model CronoJob 2015-03-06 23:16:43 +02:00
Dzmitry Plashchynski
20135b87ae Add migration to generator 2015-03-06 22:33:13 +02:00
Dzmitry Plashchynski
0f32f8a5a4 Refactoring 2015-03-05 15:29:31 +02:00
Dzmitry Plashchynski
59e71e89f3 Rename Schedule to Scheduler 2015-03-05 15:13:48 +02:00
Dzmitry Plashchynski
368d9ee0a9 Abort on exceptions in workers 2015-03-05 14:58:17 +02:00
Dzmitry Plashchynski
db6df90beb Add to log information about performing time 2015-03-05 14:53:26 +02:00
Dzmitry Plashchynski
2109be7bba Fix time formating 2015-03-05 14:52:56 +02:00
Dzmitry Plashchynski
9ca68b305f Replace loop by while 2015-03-05 14:14:35 +02:00
Dzmitry Plashchynski
750ecb98dd Mirroring logger to CLI class 2015-03-05 14:12:37 +02:00
Dzmitry Plashchynski
8ce3673368 Print schedule on load 2015-03-05 14:06:40 +02:00
Dzmitry Plashchynski
007989fa2c Add Job#description 2015-03-05 14:03:07 +02:00
Dzmitry Plashchynski
5b66e9049b Add Period#description 2015-03-05 14:02:44 +02:00
Dzmitry Plashchynski
527f4768bc Update Gemfile.lock 2015-03-04 23:41:57 +02:00
Dzmitry Plashchynski
dfae4015f8 Fix Changes.md formating 2015-03-04 18:53:38 +02:00
Dzmitry Plashchynski
27f949de10 Bump to 0.5.2 2015-03-04 18:50:53 +02:00
Dzmitry Plashchynski
78fa0f9dae Fix: Next performing time should be related to last performing time 2015-03-04 18:46:44 +02:00
Dzmitry Plashchynski
828488a6bc Add Job class 2015-03-04 18:31:59 +02:00
Dzmitry Plashchynski
36c35bce7d Add Support section to README 2015-03-04 16:07:40 +02:00
Dzmitry Plashchynski
ec53c8376f Bump to 0.5.1 2015-03-04 14:45:40 +02:00
Dzmitry Plashchynski
905b02a276 Note about capistrano-crono 2015-03-04 14:42:34 +02:00
Dzmitry Plashchynski
e96d71552e Add option -e to set environment 2015-03-04 14:26:44 +02:00
Dzmitry Plashchynski
0afdab02ac add monit example 2015-03-04 13:49:41 +02:00
29 changed files with 399 additions and 86 deletions

View File

@@ -2,3 +2,18 @@
-----------
- Initial release!
0.5.1
-----------
- Added -e/--environment ENV option to set the daemon rails environment.
0.5.2
-----------
- Fix: Scheduled time now related to the last performing time.
0.6.1
-----------
- Persist job state to your database.

View File

@@ -1,8 +1,9 @@
PATH
remote: .
specs:
crono (0.5.0)
crono (0.6.1)
activejob (~> 4.0)
activerecord (~> 4.0)
activesupport (~> 4.0)
GEM
@@ -11,12 +12,21 @@ GEM
activejob (4.2.0)
activesupport (= 4.2.0)
globalid (>= 0.3.0)
activemodel (4.2.0)
activesupport (= 4.2.0)
builder (~> 3.1)
activerecord (4.2.0)
activemodel (= 4.2.0)
activesupport (= 4.2.0)
arel (~> 6.0)
activesupport (4.2.0)
i18n (~> 0.7)
json (~> 1.7, >= 1.7.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
arel (6.0.0)
builder (3.2.2)
diff-lcs (1.2.5)
globalid (0.3.3)
activesupport (>= 4.1.0)
@@ -37,6 +47,7 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.2.0)
rspec-support (3.2.2)
sqlite3 (1.3.10)
thread_safe (0.3.4)
timecop (0.7.3)
tzinfo (1.2.2)
@@ -50,4 +61,5 @@ DEPENDENCIES
crono!
rake (~> 10.0)
rspec (~> 3.0)
sqlite3
timecop (~> 0.7)

View File

@@ -11,7 +11,7 @@ Crono is a time-based background job scheduler daemon (just like Cron) for Ruby
## The Idea
Currently there is no such thing as Cron in Ruby for Rails. Well, there's [Whenever](https://github.com/javan/whenever) but it works on top of Unix Cron, so you have no total control of it from Ruby. Crono is pure Ruby. It doesn't use Unix Cron and other platform-dependent things. So you can use it on all platforms supported by Ruby. You have total control of jobs performing process. You have the code in Ruby, so you can understand and modify it to fit your needs.
Currently there is no such thing as Cron in Ruby for Rails. Well, there's [Whenever](https://github.com/javan/whenever) but it works on top of Unix Cron, so you have no total control of it from Ruby. Crono is pure Ruby. It doesn't use Unix Cron and other platform-dependent things. So you can use it on all platforms supported by Ruby. It persists job state to your database using Active Record. You have full control of jobs performing process. You have Ruby code, so you can understand and modify it to fit your needs.
## Requirements
@@ -26,12 +26,16 @@ Add the following line to your application's Gemfile:
gem 'crono'
Run the bundle command to install it.
Run the `bundle` command to install it.
After you install Crono, you can run the generator:
rails generate crono:install
It will create a configuration file `config/cronotab.rb`
It will create a configuration file `config/cronotab.rb` and migration
Run the migration:
rake db:migrate
Now you are ready to move forward to create a job and schedule it.
@@ -42,8 +46,8 @@ Now you are ready to move forward to create a job and schedule it.
Crono can use Active Job jobs from `app/jobs/`. The only requirements is that the `perform` method should take no arguments.
Here's an example of a test job:
app/jobs/test_job.rb
# app/jobs/test_job.rb
class TestJob < ActiveJob::Base
def perform
# put you scheduled code here
@@ -65,6 +69,7 @@ The ActiveJob jobs is convenient because you can use one job in both periodic an
The schedule described in the configuration file `config/cronotab.rb`, that created using `crono:install` or manually. The semantic is pretty straightforward:
# config/cronotab.rb
Crono.perform(TestJob).every 2.days, at: "15:30"
You can schedule one job a few times, if you want a job to be performed a few times a day:
@@ -90,10 +95,19 @@ Usage: crono [options]
-L, --logfile PATH Path to writable logfile (Default: log/crono.log)
-P, --pidfile PATH Path to pidfile (Default: tmp/pids/crono.pid)
-d, --[no-]daemonize Daemonize process (Default: false)
-e, --environment ENV Application environment (Default: development)
```
## Capistrano
Use the `capistrano-crono` gem ([github](https://github.com/plashchynski/capistrano-crono/)).
## Support
Feel free to create [issues](https://github.com/plashchynski/crono/issues)
## License
Copyright 2015 Dzmitry Plashchynski <plashchynski@gmail.com>
Licensed under the Apache License, Version 2.0
Please see [LICENSE](https://github.com/plashchynski/crono/blob/master/LICENSE) for licensing details.

View File

@@ -5,8 +5,7 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
require "crono/cli"
begin
cli = Crono::CLI.instance
cli.run
Crono::CLI.instance.run
rescue => e
raise e if $DEBUG
STDERR.puts e.message

View File

@@ -15,10 +15,12 @@ Gem::Specification.new do |s|
s.add_runtime_dependency "activejob", "~> 4.0"
s.add_runtime_dependency "activesupport", "~> 4.0"
s.add_runtime_dependency "activerecord", "~> 4.0"
s.add_development_dependency "rake", "~> 10.0"
s.add_development_dependency "bundler", ">= 1.0.0"
s.add_development_dependency "rspec", "~> 3.0"
s.add_development_dependency "timecop", "~> 0.7"
s.add_development_dependency "sqlite3"
s.files = `git ls-files`.split("\n")
s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact

15
examples/cronotab.rb Normal file
View File

@@ -0,0 +1,15 @@
# cronotab.rb — Crono configuration example file
#
# Here you can specify periodic jobs and their schedule.
# You can specify a periodic job as a ActiveJob class in `app/jobs/`
# Actually you can use any class. The only requirement is that
# the class should implement a method `perform` without arguments.
#
class TestJob
def perform
puts "Test!"
end
end
Crono.perform(TestJob).every 5.second

6
examples/monitrc.conf Normal file
View File

@@ -0,0 +1,6 @@
check process crono_myapp
with pidfile /path/to/crono.pid
start program = "bundle exec crono -e production" with timeout 30 seconds
stop program = "kill -s TERM `cat /path/to/crono.pid`" with timeout 30 seconds
if totalmem is greater than 500 MB for 2 cycles then restart
group myapp_crono

View File

@@ -3,7 +3,10 @@ end
require "active_support/all"
require "crono/version.rb"
require "crono/logging.rb"
require "crono/period.rb"
require "crono/schedule.rb"
require "crono/job.rb"
require "crono/scheduler.rb"
require "crono/config.rb"
require "crono/performer_proxy.rb"
require "crono/orm/active_record/crono_job.rb"

View File

@@ -2,27 +2,34 @@ require 'crono'
require 'optparse'
module Crono
mattr_accessor :schedule
mattr_accessor :scheduler
class CLI
include Singleton
include Logging
attr_accessor :config
attr_accessor :schedule
attr_accessor :logger
def initialize
self.config = Config.new
self.schedule = Schedule.new
Crono.schedule = schedule
Crono.scheduler = Scheduler.new
end
def run
parse_options(ARGV)
init_logger
daemonize if config.daemonize
if config.daemonize
set_log_to(config.logfile)
daemonize
else
set_log_to(STDOUT)
end
write_pid
load_rails
print_banner
check_jobs
start_working_loop
end
@@ -34,6 +41,7 @@ module Crono
File.open(config.logfile, 'ab') { |f| io.reopen(f) }
io.sync = true
end
$stdin.reopen("/dev/null")
end
@@ -42,33 +50,35 @@ module Crono
File.write(pidfile, ::Process.pid)
end
def init_logger
logfile = config.daemonize ? config.logfile : STDOUT
self.logger = Logger.new(logfile)
end
def print_banner
logger.info "Loading Crono #{Crono::VERSION}"
logger.info "Running in #{RUBY_DESCRIPTION}"
logger.info "Jobs:"
Crono.scheduler.jobs.each do |job|
logger.info job.description
end
end
def load_rails
ENV['RACK_ENV'] = ENV['RAILS_ENV'] = config.environment
require 'rails'
require File.expand_path("config/environment.rb")
::Rails.application.eager_load!
require File.expand_path(config.cronotab)
end
def run_job(klass)
logger.info "Perform #{klass}"
Thread.new { klass.new.perform }
def check_jobs
if Crono.scheduler.jobs.empty?
logger.error "You have no jobs defined in you cronotab file #{config.cronotab}"
end
end
def start_working_loop
loop do
klass, time = schedule.next
sleep(time - Time.now)
run_job(klass)
Thread.abort_on_exception = true
while job = Crono.scheduler.next do
sleep(job.next - Time.now)
job.perform
end
end
@@ -91,7 +101,10 @@ module Crono
opts.on("-d", "--[no-]daemonize", "Daemonize process (Default: #{config.daemonize})") do |daemonize|
config.daemonize = daemonize
end
opts.on '-e', '--environment ENV', "Application environment (Default: #{config.environment})" do |env|
config.environment = env
end
end.parse!(argv)
end
end

View File

@@ -8,12 +8,14 @@ module Crono
attr_accessor :logfile
attr_accessor :pidfile
attr_accessor :daemonize
attr_accessor :environment
def initialize
self.cronotab = CRONOTAB
self.logfile = LOGFILE
self.pidfile = PIDFILE
self.daemonize = false
self.environment = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || "development"
end
end
end

49
lib/crono/job.rb Normal file
View File

@@ -0,0 +1,49 @@
module Crono
class Job
include Logging
attr_accessor :performer
attr_accessor :period
attr_accessor :last_performed_at
def initialize(performer, period)
self.performer, self.period = performer, period
end
def next
next_time = period.next(since: last_performed_at)
next_time.past? ? period.next : next_time
end
def description
"Perform #{performer} #{period.description}"
end
def job_id
description
end
def perform
logger.info "Perform #{performer}"
self.last_performed_at = Time.now
save
Thread.new do
performer.new.perform
logger.info "Finished #{performer} in %.2f seconds" % (Time.now - last_performed_at)
end
end
def save
model.update(last_performed_at: last_performed_at)
end
def load
self.last_performed_at = model.last_performed_at
end
private
def model
@model ||= Crono::CronoJob.find_or_create_by(job_id: job_id)
end
end
end

13
lib/crono/logging.rb Normal file
View File

@@ -0,0 +1,13 @@
module Crono
mattr_accessor :logger
module Logging
def set_log_to(logfile)
Crono.logger = Logger.new(logfile)
end
def logger
Crono.logger
end
end
end

View File

@@ -0,0 +1,8 @@
require 'active_record'
module Crono
class CronoJob < ActiveRecord::Base
self.table_name = "crono_jobs"
validates :job_id, presence: true, uniqueness: true
end
end

View File

@@ -1,16 +1,17 @@
module Crono
class PerformerProxy
def initialize(performer, schedule)
def initialize(performer, scheduler)
@performer = performer
@schedule = schedule
@scheduler = scheduler
end
def every(period, *args)
@schedule.add(@performer, Period.new(period, *args))
job = Job.new(@performer, Period.new(period, *args))
@scheduler.add_job(job)
end
end
def self.perform(performer)
PerformerProxy.new(performer, Crono.schedule)
PerformerProxy.new(performer, Crono.scheduler)
end
end

View File

@@ -5,8 +5,15 @@ module Crono
@at_hour, @at_min = parse_at(at) if at
end
def next
@period.from_now.change({hour: @at_hour, min: @at_min}.compact)
def next(since: nil)
since ||= Time.now
@period.since(since).change({hour: @at_hour, min: @at_min}.compact)
end
def description
desc = "every #{@period.inspect}"
desc += " at %.2i:%.2i" % [@at_hour, @at_min] if @at_hour && @at_min
desc
end
def parse_at(at)

View File

@@ -1,20 +0,0 @@
module Crono
class Schedule
def initialize
@schedule = []
end
def add(peformer, period)
@schedule << [peformer, period]
end
def next
[queue.first[0], queue.first[1].next]
end
private
def queue
@schedule.sort { |a,b| a[1].next <=> b[1].next }
end
end
end

23
lib/crono/scheduler.rb Normal file
View File

@@ -0,0 +1,23 @@
module Crono
class Scheduler
attr_accessor :jobs
def initialize
self.jobs = []
end
def add_job(job)
job.load
jobs << job
end
def next
queue.first
end
private
def queue
jobs.sort_by(&:next)
end
end
end

View File

@@ -1,3 +1,3 @@
module Crono
VERSION = "0.5.0"
VERSION = '0.6.1'
end

View File

@@ -1,12 +1,26 @@
require 'rails/generators'
require 'rails/generators/migration'
require 'rails/generators/active_record'
module Crono
module Generators
class InstallGenerator < ::Rails::Generators::Base
include Rails::Generators::Migration
def self.next_migration_number(path)
ActiveRecord::Generators::Base.next_migration_number(path)
end
desc "Installs crono and generates the necessary configuration files"
source_root File.expand_path("../templates", __FILE__)
def copy_config
template 'cronotab.rb.erb', 'config/cronotab.rb'
end
def create_migrations
migration_template 'migrations/create_crono_jobs.rb', 'db/migrate/create_crono_jobs.rb'
end
end
end
end

View File

@@ -0,0 +1,15 @@
class CreateCronoJobs < ActiveRecord::Migration
def self.up
create_table :crono_jobs do |t|
t.string :job_id, null: false
t.text :log
t.datetime :last_performed_at
t.timestamps
end
add_index :crono_jobs, [:job_id], unique: true
end
def self.down
drop_table :crono_jobs
end
end

View File

@@ -18,13 +18,6 @@ describe Crono::CLI do
end
end
describe "#run_job" do
it "should run job in separate thread" do
thread = cli.send(:run_job, TestJob).join
expect(thread).to be_stop
end
end
describe "#start_working_loop" do
it "should start working loop"
end
@@ -49,5 +42,10 @@ describe Crono::CLI do
cli.send(:parse_options, ["--daemonize"])
expect(cli.config.daemonize).to be true
end
it "should set environment" do
cli.send(:parse_options, ["--environment", "production"])
expect(cli.config.environment).to be_eql("production")
end
end
end

View File

@@ -3,11 +3,13 @@ require "spec_helper"
describe Crono::Config do
describe "#initialize" do
it "should initialize with default configuration options" do
ENV["RAILS_ENV"] = "test"
@config = Crono::Config.new
expect(@config.cronotab).to be Crono::Config::CRONOTAB
expect(@config.logfile).to be Crono::Config::LOGFILE
expect(@config.pidfile).to be Crono::Config::PIDFILE
expect(@config.daemonize).to be false
expect(@config.environment).to be_eql ENV["RAILS_ENV"]
end
end
end

62
spec/job_spec.rb Normal file
View File

@@ -0,0 +1,62 @@
require "spec_helper"
class TestJob
def perform;end
end
describe Crono::Job do
let(:period) { Crono::Period.new(2.day) }
let(:job) { Crono::Job.new(TestJob, period) }
it "should contain performer and period" do
expect(job.performer).to be TestJob
expect(job.period).to be period
end
describe "#perform" do
it "should run performer in separate thread" do
thread = job.perform.join
expect(thread).to be_stop
end
it "should call Job#save after run" do
expect(job).to receive(:save)
job.perform.join
job.send(:model).destroy
end
end
describe "#description" do
it "should return job identificator" do
expect(job.description).to be_eql("Perform TestJob every 2 days")
end
end
describe "#save" do
it "should save new job to DB" do
expect(Crono::CronoJob.where(job_id: job.job_id)).to_not exist
job.save
expect(Crono::CronoJob.where(job_id: job.job_id)).to exist
end
it "should update saved job" do
job.last_performed_at = Time.now
job.save
@crono_job = Crono::CronoJob.find_by(job_id: job.job_id)
expect(@crono_job.last_performed_at).to be_eql(job.last_performed_at)
end
end
describe "#load" do
before do
@saved_last_performed_at = job.last_performed_at = Time.now
job.save
end
it "should load info from DB" do
@job = Crono::Job.new(TestJob, period)
@job.load
expect(@job.last_performed_at).to be_eql @saved_last_performed_at
end
end
end

View File

@@ -0,0 +1,28 @@
require "spec_helper"
describe Crono::CronoJob do
let(:valid_attrs) do
{
job_id: "Perform TestJob every 3 days"
}
end
it "should validate presence of job_id" do
@crono_job = Crono::CronoJob.new()
expect(@crono_job).not_to be_valid
expect(@crono_job.errors.added?(:job_id, :blank)).to be true
end
it "should validate uniqueness of job_id" do
Crono::CronoJob.create!(job_id: "TestJob every 2 days")
@crono_job = Crono::CronoJob.create(job_id: "TestJob every 2 days")
expect(@crono_job).not_to be_valid
expect(@crono_job.errors.added?(:job_id, :taken)).to be true
end
it "should save job_id to DB" do
Crono::CronoJob.create!(valid_attrs)
@crono_job = Crono::CronoJob.find_by(job_id: valid_attrs[:job_id])
expect(@crono_job).to be_present
end
end

View File

@@ -5,8 +5,8 @@ class TestJob
end
describe Crono::PerformerProxy do
it "should add job and period to schedule" do
expect(Crono.schedule).to receive(:add).with(TestJob, kind_of(Crono::Period))
it "should add job to schedule" do
expect(Crono.scheduler).to receive(:add_job).with(kind_of(Crono::Job))
Crono.perform(TestJob).every(2.days, at: "15:30")
end
end

View File

@@ -7,6 +7,13 @@ describe Crono::Period do
end
end
describe "#description" do
it "should return period description" do
@period = Crono::Period.new(2.day, at: "15:20")
expect(@period.description).to be_eql("every 2 days at 15:20")
end
end
describe "#next" do
context "in daily basis" do
it "should return the time 2 days from now" do
@@ -30,6 +37,11 @@ describe Crono::Period do
@period = Crono::Period.new(2.day, at: 1)
}.to raise_error("Unknown 'at' format")
end
it "should return time in relation to last time" do
@period = Crono::Period.new(2.day)
expect(@period.next(since: 1.day.ago)).to be_eql(1.day.from_now)
end
end
end
end

View File

@@ -1,20 +0,0 @@
require "spec_helper"
class TestJob
def perform;end
end
describe Crono::Schedule do
describe "#next" do
it "should return next job in schedule" do
@schedule = Crono::Schedule.new
[
Crono::Period.new(3.day, at: "18:55"),
Crono::Period.new(1.day, at: "15:30"),
Crono::Period.new(7.day, at: "06:05")
].each { |period| @schedule.add(TestJob, period) }
expect(@schedule.next).to be_eql([TestJob, 1.day.from_now.change(hour: 15, min: 30)])
end
end
end

35
spec/scheduler_spec.rb Normal file
View File

@@ -0,0 +1,35 @@
require "spec_helper"
class TestJob
def perform;end
end
describe Crono::Scheduler do
before(:each) do
@scheduler = Crono::Scheduler.new
@jobs = [
Crono::Period.new(3.day, at: "18:55"),
Crono::Period.new(1.day, at: "15:30"),
Crono::Period.new(7.day, at: "06:05")
].map { |period| Crono::Job.new(TestJob, period) }
@scheduler.jobs = @jobs
end
describe "#add_job" do
it "should call Job#load on Job" do
@job = Crono::Job.new(TestJob, Crono::Period.new(10.day, at: "04:05"))
expect(@job).to receive(:load)
@scheduler.add_job(@job)
end
end
describe "#next" do
it "should return next job in schedule" do
expect(@scheduler.next).to be @jobs[1]
end
it "should return next based on last" do
expect(@scheduler.next)
end
end
end

View File

@@ -3,6 +3,11 @@ Bundler.setup
require 'timecop'
require 'crono'
require 'generators/crono/install/templates/migrations/create_crono_jobs.rb'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)
CreateCronoJobs.up
RSpec.configure do |config|
end