allora 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in allora.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Flippa.com Pty. Ltd.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # Allora: A distributed cron daemon in ruby
2
+
3
+ Allora (*Italian: at that time*) allows you to run a cron scheduler (yes, the actual
4
+ scheduler) on multiple machines within a network, without the worry of multiple
5
+ machines processing the same job on the schedule.
6
+
7
+ A centralized backend is used (by default redis) in order to maintain a shared state.
8
+ Allora also provides a basic in-memory backend, which can be used to expressly allow
9
+ jobs to run on more than one machine (e.g. to perform some file cleanup operations
10
+ directly on the machine).
11
+
12
+ I am a firm believer in keeping it simple, so you'll find Allora weighs in at just
13
+ a couple of hundred SLOC and doesn't provide a bajillion features you aren't likely
14
+ to use.
15
+
16
+ Schedules are written in pure ruby, not YAML.
17
+
18
+ The scheduler process is reentrant. That is to say, if the scheduler is due to run
19
+ a job at midnight and the process is stopped at 23:59, then restarted at 00:01, the
20
+ midnight job will still run. Reentry is smart, however: it catches back up as soon
21
+ as it has processed any overdue jobs (so it doesn't get stuck in the past).
22
+
23
+ ## Installation
24
+
25
+ Via rubygems:
26
+
27
+ gem install allora
28
+
29
+ ## Creating a schedule
30
+
31
+ The schedule is a ruby file. You execute this ruby file to start the daemon running.
32
+
33
+ If you don't have ActiveSupport available, replace `1.hour`, for example with `3600`
34
+ (seconds).
35
+
36
+ Create a file, for example "schedule.rb":
37
+
38
+ Allora.start(:join => true) do |s|
39
+ # a job that runs hourly
40
+ s.add("empty_cache", :every => 1.hour) { `rm -f /path/to/cache/*` }
41
+
42
+ # a job that runs based on a cron string
43
+ s.add("update_stats", :cron => "0 2,14 * * *") { Resque.enqueue(UpdateStatsJob) }
44
+ end
45
+
46
+ When you run this file with ruby, it will remain in the foreground, providing log
47
+ output. It is *currently* your responsibility to daemonize the process.
48
+
49
+ Note that in the above example, we're only using the in-memory backend, so this
50
+ probably shouldn't be run on multiple machines.
51
+
52
+ In the following example, we specify to use a Redis backend, which is safe to run on
53
+ multiple machines:
54
+
55
+ Allora.start(:backend => :redis, :host => "redis.lan", :join => true) do |s|
56
+ # a job that runs hourly
57
+ s.add("empty_cache", :every => 1.hour) { `rm -f /path/to/cache/*` }
58
+
59
+ # a job that runs based on a cron string
60
+ s.add("update_stats", :cron => "0 2,14 * * *") { Resque.enqueue(UpdateStatsJob) }
61
+ end
62
+
63
+ We specify a redis host (and port) so that schedule data can be shared.
64
+
65
+ ## Accessing your application environment
66
+
67
+ Allora will not make any assumptions about your application. It is your responsibility
68
+ to load it, if you need it. For Rails 3.x applications, add the following to the top
69
+ of your schedule:
70
+
71
+ require File.expand_path("../config/environment", __FILE__)
72
+
73
+ Assuming "../config/environment" resolves to the actual path where your environment.rb is
74
+ found.
75
+
76
+ ## Implementation notes
77
+
78
+ Disclaimer: The scheduler is not intended to be 100% accurate. A job set to run every
79
+ second will probably run every second, but occasionally, if polling is slow, 2 seconds
80
+ may pass between runs. If this is a problem for your application, you should not use
81
+ this gem. The focus of this gem is to support running the scheduler on multiple machines.
82
+
83
+ In order to run the scheduler on more than one machine, Allora uses a `Backend` class to
84
+ maintain state. The timestamp at which a job should next run is kept in the backend.
85
+ When the scheduler polls, it asks the backend to return any jobs that can be run *and*
86
+ update the time at which they should next run. A locking strategy is used to ensure no
87
+ two machines update the schedule information at the same time.
88
+
89
+ In short, whichever running scheduler finds a job to do is the same scheduler the sets the
90
+ next time that job should run.
91
+
92
+ Jobs are executed in forked children, so that if they crash, the scheduler does not
93
+ exit.
94
+
95
+ ## Custom Job classes
96
+
97
+ Allora offers two types of Job, which make sense for scheduling work at set intervals.
98
+ These are the `:every` and `:cron` types of job, which map to `Allora::Job::EveryJob` and
99
+ `Allora::Job::CronJob` internally. You may write your own subclass of `Allora::Job`, if
100
+ you have some specific need that is not met by either of these job types.
101
+
102
+ Job classes simply need to implement the `#next_at` method, which accepts a `Time` as
103
+ input and returns a time after that at which the job should run. `Allora::Job` will
104
+ handle the execution of the job itself.
105
+
106
+ Here's the implementation of the `EveryJob` class:
107
+
108
+ module Allora
109
+ class Job::EveryJob < Job
110
+ def initialize(n, &block)
111
+ @duration = n
112
+
113
+ super(&block)
114
+ end
115
+
116
+ def next_at(from_time)
117
+ from_time + @duration
118
+ end
119
+ end
120
+ end
121
+
122
+ Quite simply it adds whatever the duration is to the given time.
123
+
124
+ To use your custom Job class, pass the instance to `Scheduler#add`:
125
+
126
+ s.add("foo", MyJob.new { puts "Running custom job" })
127
+
128
+ ## Custom Backend classes
129
+
130
+ It is more likely that you will wish to write a custom backend, than a custom job. In
131
+ particular if you do not wish to use Redis, which is currently the only provided option.
132
+
133
+ Backend classes subclass `Allora::Backend` and implement `#reschedule`. The `#reschedule`
134
+ method accepts a Hash of jobs to check and does two things:
135
+
136
+ 1. Returns a new Hash containing any jobs that can run now
137
+ 2. Internally updates the time at which the job should next run
138
+
139
+ A locking strategy should be used in order to ensure the backend supports running on
140
+ multiple machines.
141
+
142
+ For the sake of clarity and brevity, here is a pseudo-code example:
143
+
144
+ class MyBackend < Allora::Backend
145
+ def reschedule(jobs)
146
+ now = Time.now
147
+ jobs.select do |name, job|
148
+ schedule_if_new(name, job.next_at(now))
149
+ lock_job(name) do # returns the result of the block only if successful
150
+ next_run = scheduled_time(name)
151
+ if next_run <= now
152
+ update_schedule(name, job.next_at(now))
153
+ true
154
+ else
155
+ false
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ The backend sets a new time into its internal schedule if none is present for that job.
163
+
164
+ It then tries to gain a lock on the schedule information for that job, returning false
165
+ if not possible (and thus not selecting the job from the input Hash).
166
+
167
+ If a lock was acquired, the time at which the job should run is checked. If it is in the
168
+ past, the scheule information is advanced to the next time at which the job should run and
169
+ the job is selected, else the job is not selected.
170
+
171
+ ## Credits
172
+
173
+ Big thanks for jmettraux for rufus-scheduler, which I have borrowed the cron parsing logic
174
+ from.
175
+
176
+ ## Disclaimer
177
+
178
+ Most of this work is the result of a quick code spike on a Sunday afternoon. There are no
179
+ specs right now. Use at your own risk. I will add specs in the next day or two, if you
180
+ prefer to wait.
181
+
182
+ ## Copyright & License
183
+
184
+ Copyright &copy; 2012 Flippa.com Pty. Ltd. See LICENSE file for details.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/allora.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "allora/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "allora"
7
+ s.version = Allora::VERSION
8
+ s.authors = ["d11wtq"]
9
+ s.email = ["chris@w3style.co.uk"]
10
+ s.homepage = "https://github.com/flippa/allora"
11
+ s.summary = %q{A ruby scheduler that keeps it simple, with support for distributed schedules}
12
+ s.description = %q{Allora (Italian for "at that time") provides a replacement for the classic UNIX
13
+ cron, using nothing but ruby. It is very small, easy to follow and relatively
14
+ feature-light. It does support a locking mechanism, backed by Redis, or any
15
+ other custom implementation, which makes it possible to run the scheduler on
16
+ more than one server, without worrying about jobs executing more than once per
17
+ scheduled time.}
18
+
19
+ s.rubyforge_project = "allora"
20
+
21
+ s.files = `git ls-files`.split("\n")
22
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
23
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
24
+ s.require_paths = ["lib"]
25
+
26
+ s.add_development_dependency "redis"
27
+ end
@@ -0,0 +1,50 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # A basic, single-process backend using a Hash.
26
+ #
27
+ # You should not run this on multiple machines on the same network.
28
+ class Backend::Memory < Backend
29
+ # Initialize a new Memory backend.
30
+ #
31
+ # @params [Hash] opts
32
+ # this backend does not accept any options
33
+ def initialize(opts = {})
34
+ super
35
+
36
+ @schedule = {}
37
+ end
38
+
39
+ def reschedule(jobs)
40
+ current_time = Time.now
41
+ last_time = (@last_time ||= Time.now)
42
+ @last_time = current_time
43
+
44
+ jobs.select do |name, job|
45
+ @schedule[name] ||= job.next_at(last_time)
46
+ @schedule[name] < current_time && @schedule[name] = job.next_at(current_time)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,126 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # A backend that uses Redis to maintain schedule state.
26
+ #
27
+ # When using this backend, it is possible to run the scheduler process
28
+ # on more than one machine in the network, connected to the same Redis
29
+ # instace. Whichever scheduler finds a runnable job first updates the
30
+ # 'next run time' information in Redis, using an optimistic locking
31
+ # strategy, then executes the job if the write succeeds. No two
32
+ # machines will ever run the same job twice.
33
+ class Backend::Redis < Backend
34
+ attr_reader :redis
35
+ attr_reader :prefix
36
+
37
+ # Initialize the Redis backed with the given options.
38
+ #
39
+ # Options:
40
+ # client: an already instantiated Redis client object.
41
+ # host: the hostname of a Redis server
42
+ # port: the port number of a Redis server
43
+ # prefix: a namespace prefix to use
44
+ #
45
+ # @param [Hash] opts
46
+ # options for the Redis backend
47
+ def initialize(opts = {})
48
+ @redis = create_redis(opts)
49
+ @prefix = opts.fetch(:prefix, "allora")
50
+
51
+ reset!
52
+ end
53
+
54
+ def reschedule(jobs)
55
+ current_time = Time.now
56
+ last_time = send(:last_time)
57
+ set_last_time(current_time)
58
+
59
+ jobs.select do |name, job|
60
+ redis.hsetnx(schedule_info_key, name, 1)
61
+ redis.setnx(job_info_key(name), time_to_int(job.next_at(last_time)))
62
+ update_job_info(job, name, current_time)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def create_redis(opts)
69
+ return opts[:client] if opts.key?(:client)
70
+
71
+ ::Redis.new(:host => opts.fetch(:host, "localhost"), :port => opts.fetch(:port, 6379))
72
+ end
73
+
74
+ # Forces all job data to be re-entered into Redis at the next poll
75
+ def reset!
76
+ redis.hgetall(schedule_info_key) { |name, t| redis.del(job_info_key(name)) }
77
+ redis.del(schedule_info_key)
78
+ end
79
+
80
+ # Returns a Boolean specifying if the job can be run and no race condition occurred updating its info
81
+ def update_job_info(job, name, time)
82
+ redis.watch(job_info_key(name))
83
+ run_at = int_to_time(redis.get(job_info_key(name)))
84
+
85
+ if run_at <= time
86
+ redis.multi
87
+ redis.set(job_info_key(name), time_to_int(job.next_at(time)))
88
+ redis.exec
89
+ else
90
+ redis.unwatch && false
91
+ end
92
+ end
93
+
94
+ def schedule_info_key
95
+ "#{prefix}_schedule"
96
+ end
97
+
98
+ def job_info_key(name)
99
+ "#{prefix}_job_#{name}"
100
+ end
101
+
102
+ def last_time_key
103
+ "#{prefix}_last_run"
104
+ end
105
+
106
+ # Returns the last time at which polling occurred
107
+ #
108
+ # This is used as a re-entry mechanism if the scheduler stops
109
+ def last_time
110
+ redis.setnx(last_time_key, time_to_int(Time.now))
111
+ int_to_time(redis.get(last_time_key))
112
+ end
113
+
114
+ def set_last_time(t)
115
+ redis.set(last_time_key, time_to_int(t))
116
+ end
117
+
118
+ def time_to_int(t)
119
+ t.to_i
120
+ end
121
+
122
+ def int_to_time(i)
123
+ Time.at(i.to_i)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,52 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ class Backend
26
+ attr_reader :options
27
+
28
+ # Initialize the backend with the given options Hash.
29
+ #
30
+ # @param [Hash] opts
31
+ # options for the backend, if the backend requires any
32
+ def initialize(opts = {})
33
+ @options = opts
34
+ end
35
+
36
+ # Reschedules jobs in the given Hash and returns those that should run now.
37
+ #
38
+ # Subclasses should take an approach that tracks the run time information and updates
39
+ # it in a way that avoids race conditions. The job should not be run until it can be
40
+ # guaranteed that it has been rescheduled for a future time and no other scheduler
41
+ # process executed the job first.
42
+ #
43
+ # @param [Hash] jobs
44
+ # a Hash mapping job names with their job classes
45
+ #
46
+ # @return [Hash]
47
+ # a Hash containing the jobs to be run now, if any
48
+ def reschedule(jobs)
49
+ raise NotImplementedError, "Abstract method #reschedule must be implemented by subclass"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,244 @@
1
+ #--
2
+ # Copyright (c) 2006-2012, John Mettraux, jmettraux@gmail.com
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #
22
+ # Made in Japan.
23
+ #++
24
+
25
+ # This is almost entirely lifted from https://github.com/jmettraux/rufus-scheduler/blob/master/lib/rufus/sc/cronline.rb
26
+ module Allora
27
+ # Parses a crontab string to determine the times it represents.
28
+ class CronLine
29
+ attr_reader :seconds
30
+ attr_reader :minutes
31
+ attr_reader :hours
32
+ attr_reader :days
33
+ attr_reader :months
34
+ attr_reader :weekdays
35
+ attr_reader :monthdays
36
+ attr_reader :timezone
37
+
38
+ def initialize(line)
39
+ items = line.split
40
+
41
+ raise ArgumentError.new("not a valid cronline : '#{line}'") \
42
+ unless items.length == 5 or items.length == 6
43
+
44
+ offset = items.length - 5
45
+
46
+ @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [0]
47
+ @minutes = parse_item(items[0 + offset], 0, 59)
48
+ @hours = parse_item(items[1 + offset], 0, 24)
49
+ @days = parse_item(items[2 + offset], 1, 31)
50
+ @months = parse_item(items[3 + offset], 1, 12)
51
+
52
+ @weekdays, @monthdays = parse_weekdays(items[4 + offset])
53
+ end
54
+
55
+ # Returns the next time that this cron line is supposed to 'fire'
56
+ #
57
+ # Note that the time instance returned will be in the same time zone that
58
+ # the given start point Time (thus a result in the local time zone will
59
+ # be passed if no start time is specified (search start time set to
60
+ # Time.now))
61
+ #
62
+ # @example
63
+ #
64
+ # Allora::CronLine.new('30 7 * * *').next_time(
65
+ # Time.mktime(2008, 10, 24, 7, 29))
66
+ # #=> Fri Oct 24 07:30:00 -0500 2008
67
+ #
68
+ # Allora::CronLine.new('30 7 * * *').next_time(
69
+ # Time.utc(2008, 10, 24, 7, 29))
70
+ # #=> Fri Oct 24 07:30:00 UTC 2008
71
+ #
72
+ # Allora::CronLine.new('30 7 * * *').next_time(
73
+ # Time.utc(2008, 10, 24, 7, 29)).localtime
74
+ # #=> Fri Oct 24 02:30:00 -0500 2008
75
+ #
76
+ # @param [Time] time
77
+ # the time from which to compute the next time
78
+ #
79
+ # @return [Time]
80
+ # the next time after the given Time
81
+ def next_time(time)
82
+ # little adjustment before starting
83
+ time = time + 1
84
+
85
+ loop do
86
+ unless date_match?(time)
87
+ time += (24 - time.hour) * 3600 - time.min * 60 - time.sec
88
+ next
89
+ end
90
+ unless sub_match?(time.hour, @hours)
91
+ time += (60 - time.min) * 60 - time.sec
92
+ next
93
+ end
94
+ unless sub_match?(time.min, @minutes)
95
+ time += 60 - time.sec
96
+ next
97
+ end
98
+ unless sub_match?(time.sec, @seconds)
99
+ time += 1
100
+ next
101
+ end
102
+
103
+ break
104
+ end
105
+
106
+ time
107
+ end
108
+
109
+ def to_array
110
+ [
111
+ @seconds,
112
+ @minutes,
113
+ @hours,
114
+ @days,
115
+ @months,
116
+ @weekdays,
117
+ @monthdays
118
+ ]
119
+ end
120
+
121
+ private
122
+
123
+ WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
124
+
125
+ def parse_weekdays(item)
126
+ return nil if item == '*'
127
+
128
+ items = item.downcase.split(',')
129
+
130
+ weekdays = nil
131
+ monthdays = nil
132
+
133
+ items.each do |it|
134
+ if it.match(/#[12345]$/)
135
+ raise ArgumentError.new(
136
+ "ranges are not supported for monthdays (#{it})"
137
+ ) if it.index('-')
138
+
139
+ (monthdays ||= []) << it
140
+ else
141
+ WEEKDAYS.each_with_index { |a, i| it.gsub!(/#{a}/, i.to_s) }
142
+
143
+ its = it.index('-') ? parse_range(it, 0, 7) : [Integer(it)]
144
+ its = its.collect { |i| i == 7 ? 0 : i }
145
+
146
+ (weekdays ||= []).concat(its)
147
+ end
148
+ end
149
+
150
+ weekdays = weekdays.uniq if weekdays
151
+
152
+ [weekdays, monthdays]
153
+ end
154
+
155
+ def parse_item(item, min, max)
156
+ return nil if item == '*'
157
+ return parse_list(item, min, max) if item.index(',')
158
+ return parse_range(item, min, max) if item.index('*') or item.index('-')
159
+
160
+ i = item.to_i
161
+
162
+ i = min if i < min
163
+ i = max if i > max
164
+
165
+ [i]
166
+ end
167
+
168
+ def parse_list(item, min, max)
169
+ item.split(',').inject([]) { |r, i|
170
+ r.push(parse_range(i, min, max))
171
+ }.flatten
172
+ end
173
+
174
+ def parse_range(item, min, max)
175
+ i = item.index('-')
176
+ j = item.index('/')
177
+
178
+ return item.to_i if (not i and not j)
179
+
180
+ inc = j ? item[j + 1..-1].to_i : 1
181
+
182
+ istart = -1
183
+ iend = -1
184
+
185
+ if i
186
+ istart = item[0..i - 1].to_i
187
+ iend = if j
188
+ item[i + 1..j - 1].to_i
189
+ else
190
+ item[i + 1..-1].to_i
191
+ end
192
+ else # case */x
193
+ istart = min
194
+ iend = max
195
+ end
196
+
197
+ istart = min if istart < min
198
+ iend = max if iend > max
199
+
200
+ result = []
201
+
202
+ value = istart
203
+ loop do
204
+ result << value
205
+ value = value + inc
206
+ break if value > iend
207
+ end
208
+
209
+ result
210
+ end
211
+
212
+ def sub_match?(value, values)
213
+ values.nil? || values.include?(value)
214
+ end
215
+
216
+ def monthday_match(monthday, monthdays)
217
+ return true if monthdays == nil
218
+ return true if monthdays.include?(monthday)
219
+ end
220
+
221
+ def date_match?(date)
222
+ return false unless sub_match?(date.day, @days)
223
+ return false unless sub_match?(date.month, @months)
224
+ return false unless sub_match?(date.wday, @weekdays)
225
+ return false unless sub_match?(CronLine.monthday(date), @monthdays)
226
+ true
227
+ end
228
+
229
+ DAY_IN_SECONDS = 7 * 24 * 3600
230
+
231
+ def self.monthday(date)
232
+ count = 1
233
+ date2 = date.dup
234
+
235
+ loop do
236
+ date2 = date2 - DAY_IN_SECONDS
237
+ break if date2.month != date.month
238
+ count = count + 1
239
+ end
240
+
241
+ "#{WEEKDAYS[date.wday]}##{count}"
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,46 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # A classic cron style job, with support for seconds.
26
+ class Job::CronJob < Job
27
+ # Initialize the CronJob with the given cron string.
28
+ #
29
+ # @param [String] cron_str
30
+ # any valid cron string, which may include seconds
31
+ #
32
+ # @example
33
+ # CronJob.new("*/5 * * * * *") # every 5s
34
+ # CronJob.new("0,30 * * * *") # the 0th and 30th min of each hour
35
+ # CronJob.new("0 3-6 * * *") # on the hour, every hour between 3am and 6am
36
+ def initialize(cron_str, &block)
37
+ super(&block)
38
+
39
+ @cron_line = CronLine.new(cron_str)
40
+ end
41
+
42
+ def next_at(from_time)
43
+ @cron_line.next_time(from_time)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,48 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # A very simple job type that simply repeats every +n+ seconds.
26
+ class Job::EveryJob < Job
27
+ # Initialize the job to run every +n+ seconds.
28
+ #
29
+ # @param [Integer] n
30
+ # the number of seconds to wait between executions
31
+ #
32
+ # You may use ActiveSupport's numeric helpers, if you have ActiveSupport
33
+ # available.
34
+ #
35
+ # @example Using ActiveSupport
36
+ # EveryJob.new(15.seconds) { puts "Boo!" }
37
+ #
38
+ def initialize(n, &block)
39
+ @duration = n
40
+
41
+ super(&block)
42
+ end
43
+
44
+ def next_at(from_time)
45
+ from_time + @duration
46
+ end
47
+ end
48
+ end
data/lib/allora/job.rb ADDED
@@ -0,0 +1,57 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # Abstract job class providing a wrapper around job execution.
26
+ #
27
+ # Subclasses must be able to provide a time for the job to run,
28
+ # given a start time.
29
+ class Job
30
+ attr_reader :block
31
+
32
+ # Initialize the job with the given block to invoke during execution.
33
+ def initialize(&block)
34
+ @block = block
35
+ end
36
+
37
+ # Execute the job.
38
+ #
39
+ # Execution happens inside a forked and detached child.
40
+ def execute
41
+ Process.detach(fork { @block.call })
42
+ end
43
+
44
+ # Returns the next time at which this job should run.
45
+ #
46
+ # Subclasses must implement this method.
47
+ #
48
+ # @param [Time] from_time
49
+ # the time from which to calculate the next run time
50
+ #
51
+ # @return [Time]
52
+ # the time at which the job should next run
53
+ def next_at(from_time)
54
+ raise NotImplementedError, "Abstract method #next_at must be overridden by subclasses"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,130 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ # Worker daemon, dealing with a Backend to execute jobs at regular intervals.
26
+ class Scheduler
27
+ attr_reader :jobs
28
+ attr_reader :backend
29
+
30
+ # Initialize the Scheduler with the given options.
31
+ #
32
+ # Options:
33
+ # backend: an instance of any Backend class (defaults to Memory)
34
+ # interval: a floating point specifying how frequently to poll (defaults to 0.333)
35
+ # logger: an instance of a ruby Logger, or nil to disable
36
+ # *other: any additonal parameters are passed to the Backend
37
+ #
38
+ # @param [Hash] options
39
+ # options for the scheduler, if any
40
+ def initialize(opts = {})
41
+ require "logger"
42
+
43
+ @backend = create_backend(opts)
44
+ @interval = opts.fetch(:interval, 0.333)
45
+ @logger = opts.fetch(:logger, Logger.new(STDOUT))
46
+ @jobs = {}
47
+ end
48
+
49
+ # Register a new job for the given options.
50
+ #
51
+ # @example
52
+ # s.add("foo", :every => 5.seconds) { puts "Running!" }
53
+ # s.add("bar", :cron => "*/15 * 1,10,20 * *") { puts "Bonus!" }
54
+ #
55
+ # @param [String] name
56
+ # a unique name to give this job (used for locking)
57
+ #
58
+ # @param [Hash, Job] opts_or_job
59
+ # options specifying when to run the job (:every, or :cron), or a Job instance.
60
+ #
61
+ # @return [Job]
62
+ # the job instance added to the schedule
63
+ def add(name, opts_or_job, &block)
64
+ jobs[name.to_s] = create_job(opts_or_job, &block)
65
+ end
66
+
67
+ # Starts running the scheduler in a new Thread, and returns that Thread.
68
+ #
69
+ # @return [Thread]
70
+ # the scheduler polling Thread
71
+ def start
72
+ log "Starting scheduler process, using #{@backend.class}"
73
+
74
+ @thread = Thread.new do
75
+ loop do
76
+ @backend.reschedule(@jobs).each do |name, job|
77
+ log "Running job '#{name}'"
78
+ job.execute
79
+ end
80
+
81
+ sleep(@interval)
82
+ end
83
+ end
84
+ end
85
+
86
+ # Stop the currently running scheduler Thread
87
+ def stop
88
+ log "Exiting scheduler process"
89
+ @thread.exit
90
+ end
91
+
92
+ # Join the currently running scheduler Thread.
93
+ #
94
+ # This should be invoked to prevent the parent Thread from terminating.
95
+ def join
96
+ @thread.join
97
+ end
98
+
99
+ private
100
+
101
+ def create_job(opts, &block)
102
+ return opts if Job === opts
103
+
104
+ raise ArgumentError "Missing schedule key (either :cron, or :every)" \
105
+ unless opts.key?(:cron) || opts.key?(:every)
106
+
107
+ if opts.key?(:every)
108
+ Job::EveryJob.new(opts[:every], &block)
109
+ elsif opts.key?(:cron)
110
+ Job::CronJob.new(opts[:cron], &block)
111
+ end
112
+ end
113
+
114
+ def create_backend(opts)
115
+ return Backend::Memory.new unless opts.key?(:backend)
116
+
117
+ case opts[:backend]
118
+ when :memory then Backend::Memory.new
119
+ when :redis then Backend::Redis.new(opts)
120
+ when Class then opts[:backend].new(opts)
121
+ when Backend then opts[:backend]
122
+ else raise "Unsupported backend '#{opts[:backend].inspect}'"
123
+ end
124
+ end
125
+
126
+ def log(str)
127
+ @logger.info("Allora: #{str}") if @logger
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,26 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ module Allora
25
+ VERSION = "0.0.1"
26
+ end
data/lib/allora.rb ADDED
@@ -0,0 +1,57 @@
1
+ ##
2
+ # Permission is hereby granted, free of charge, to any person obtaining
3
+ # a copy of this software and associated documentation files (the
4
+ # "Software"), to deal in the Software without restriction, including
5
+ # without limitation the rights to use, copy, modify, merge, publish,
6
+ # distribute, sublicense, and/or sell copies of the Software, and to
7
+ # permit persons to whom the Software is furnished to do so, subject to
8
+ # the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be
11
+ # included in all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
17
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
19
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # Copyright &copy; 2012 Flippa.com Pty. Ltd.
22
+ ##
23
+
24
+ require "allora/version"
25
+
26
+ require "allora/scheduler"
27
+
28
+ require "allora/backend"
29
+ require "allora/backend/memory"
30
+ require "allora/backend/redis"
31
+
32
+ require "allora/cron_line"
33
+
34
+ require "allora/job"
35
+ require "allora/job/every_job"
36
+ require "allora/job/cron_job"
37
+
38
+ module Allora
39
+ class << self
40
+ # Create a new Scheduler, yield it and then start it.
41
+ #
42
+ # If the `:join` option is specified, the scheduler Thread is joined.
43
+ #
44
+ # @params [Hash] opts
45
+ # options specifying a Backend to use, and any backend-specific options
46
+ #
47
+ # @return [Scheduler]
48
+ # the running scheduler
49
+ def start(opts = {})
50
+ Scheduler.new(opts).tap do |s|
51
+ yield s
52
+ s.start
53
+ s.join if opts[:join]
54
+ end
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: allora
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - d11wtq
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-03-11 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: &70298724477660 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70298724477660
25
+ description: ! "Allora (Italian for \"at that time\") provides a replacement for the
26
+ classic UNIX\n cron, using nothing but ruby. It is very small,
27
+ easy to follow and relatively\n feature-light. It does support
28
+ a locking mechanism, backed by Redis, or any\n other custom
29
+ implementation, which makes it possible to run the scheduler on\n more
30
+ than one server, without worrying about jobs executing more than once per\n scheduled
31
+ time."
32
+ email:
33
+ - chris@w3style.co.uk
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - .gitignore
39
+ - Gemfile
40
+ - LICENSE
41
+ - README.md
42
+ - Rakefile
43
+ - allora.gemspec
44
+ - lib/allora.rb
45
+ - lib/allora/backend.rb
46
+ - lib/allora/backend/memory.rb
47
+ - lib/allora/backend/redis.rb
48
+ - lib/allora/cron_line.rb
49
+ - lib/allora/job.rb
50
+ - lib/allora/job/cron_job.rb
51
+ - lib/allora/job/every_job.rb
52
+ - lib/allora/scheduler.rb
53
+ - lib/allora/version.rb
54
+ homepage: https://github.com/flippa/allora
55
+ licenses: []
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ! '>='
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubyforge_project: allora
74
+ rubygems_version: 1.8.11
75
+ signing_key:
76
+ specification_version: 3
77
+ summary: A ruby scheduler that keeps it simple, with support for distributed schedules
78
+ test_files: []
79
+ has_rdoc: