allora 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: