scheduled 0.1.0

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 688c45f27921579d66859e13503929c9de904061
4
+ data.tar.gz: cc72fa658f792a10ec443ea1dd79f8ef4e069448
5
+ SHA512:
6
+ metadata.gz: f254da84808cdaa0d9c1985c52dac01ba120958241d94fcc0c7438d6c6397616b391aa5ec87e70f79242c7643a74c968a49e7e0135fc13081d17c4d3ccf01601
7
+ data.tar.gz: 61e96fa4874cd744cebd95c21914681511b9567b8b3e8e6d27090655707e83b6e95ab539a266ba2b3ec323d2ee29c7ec7e04064b9daf6a919bc3470a253612f8
@@ -0,0 +1,82 @@
1
+ # Scheduled
2
+
3
+ A simple task scheduler, akin to ClockWork or Rufus Scheduler.
4
+
5
+ ## Project Goals
6
+
7
+ * Absolutely no dependency on ActiveSupport
8
+ * Very small dependency tree
9
+ * Framework agnostic
10
+ * No built-in daemonization
11
+ * No scheduling DSL
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem "scheduled"
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install scheduled
28
+
29
+ ## Usage
30
+
31
+ ### Defining tasks
32
+
33
+ Tasks are defined by calling `Scheduled.every` with an interval and a block to execute.
34
+ The interval can be a basic Integer to represent seconds, or a callable object that receives
35
+ the current Job.
36
+
37
+ If the callable object returns a truthy value, the block is executed.
38
+
39
+ ```ruby
40
+ require "scheduled"
41
+
42
+ # Called every 60 seconds
43
+ Scheduled.every(60) { do_work } # Perform some job
44
+
45
+ # Using a cronline
46
+ Scheduled.every("* * * * *") { do_work }
47
+
48
+ two_hours_from_last_run = ->(job) do
49
+ Time.now - job.last_run >= 60*60*2
50
+ end
51
+ Scheduled.every(two_hours_from_last_run) { puts "Updating" }
52
+
53
+ # Run the scheduler
54
+ Scheduled.wait
55
+ ```
56
+
57
+ ### Running the scheduler
58
+
59
+ Provided your schedule file ends with a call to `Scheduled.wait`, just run it
60
+ as any other Ruby script.
61
+
62
+ ruby schedule.rb
63
+
64
+ ### Quick Returning Tasks
65
+
66
+ Scheduled works best if you schedule the tasks into a long-running queue, such as
67
+ Backburner or Sidekiq.
68
+
69
+ ## Contributing
70
+
71
+ Bug reports and pull requests are welcome on GitHub at https://github.com/adam12/scheduled.
72
+
73
+ I love pull requests! If you fork this project and modify it, please ping me to see
74
+ if your changes can be incorporated back into this project.
75
+
76
+ That said, if your feature idea is nontrivial, you should probably open an issue to
77
+ [discuss it](http://www.igvita.com/2011/12/19/dont-push-your-pull-requests/)
78
+ before attempting a pull request.
79
+
80
+ ## License
81
+
82
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ require "concurrent"
3
+ require "scheduled/cron_parser"
4
+
5
+ module Scheduled
6
+ Job = Struct.new(:last_run)
7
+
8
+ module_function
9
+
10
+ def every(interval, &block)
11
+ if interval.is_a?(Integer)
12
+ task = Concurrent::TimerTask.new(execution_interval: interval, run_now: true) do
13
+ block.call
14
+ end
15
+
16
+ task.execute
17
+
18
+ elsif interval.is_a?(String)
19
+ run = ->() {
20
+ parsed_cron = CronParser.new(interval)
21
+ next_tick_delay = parsed_cron.next(Time.now) - Time.now
22
+
23
+ task = Concurrent::ScheduledTask.execute(next_tick_delay) do
24
+ block.call
25
+ run.call
26
+ end
27
+
28
+ task.execute
29
+ }
30
+
31
+ run.call
32
+
33
+ elsif interval.respond_to?(:call)
34
+ job = Job.new
35
+
36
+ task = Concurrent::TimerTask.new(execution_interval: 1, run_now: true) do |timer_task|
37
+ case interval.call(job)
38
+ when true
39
+ block.call
40
+
41
+ job.last_run = Time.now
42
+ when :cancel
43
+ timer_task.shutdown
44
+ end
45
+ end
46
+
47
+ task.execute
48
+ else
49
+ raise ArgumentError, "Unsupported value for interval"
50
+ end
51
+ end
52
+
53
+ def wait
54
+ trap("INT") { exit }
55
+
56
+ loop do
57
+ sleep 1
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,322 @@
1
+ # Original source available at
2
+ # https://github.com/siebertm/parse-cron/blob/6064cc29262ac7c6ffb902c95c0c059eded960d1/lib/cron_parser.rb
3
+ #
4
+ # Copyright (C) 2013 Michael Siebert <siebertm85@googlemail.com>
5
+ # Permission is hereby granted, free of charge, to any person
6
+ # obtaining a copy of this software and associated documentation
7
+ # files (the "Software"), to deal in the Software without restriction,
8
+ # including without limitation the rights to use, copy, modify,
9
+ # merge, publish, distribute, sublicense, and/or sell copies of
10
+ # the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+
13
+ # The above copyright notice and this permission notice shall
14
+ # be included in all copies or substantial portions of the Software.
15
+
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
23
+ # OTHER DEALINGS IN THE SOFTWARE.
24
+
25
+ require 'set'
26
+ require 'date'
27
+
28
+ # Parses cron expressions and computes the next occurence of the "job"
29
+ #
30
+ class CronParser
31
+ # internal "mutable" time representation
32
+ class InternalTime
33
+ attr_accessor :year, :month, :day, :hour, :min
34
+ attr_accessor :time_source
35
+
36
+ def initialize(time,time_source = Time)
37
+ @year = time.year
38
+ @month = time.month
39
+ @day = time.day
40
+ @hour = time.hour
41
+ @min = time.min
42
+
43
+ @time_source = time_source
44
+ end
45
+
46
+ def to_time
47
+ time_source.local(@year, @month, @day, @hour, @min, 0)
48
+ end
49
+
50
+ def inspect
51
+ [year, month, day, hour, min].inspect
52
+ end
53
+ end
54
+
55
+ SYMBOLS = {
56
+ "jan" => "1",
57
+ "feb" => "2",
58
+ "mar" => "3",
59
+ "apr" => "4",
60
+ "may" => "5",
61
+ "jun" => "6",
62
+ "jul" => "7",
63
+ "aug" => "8",
64
+ "sep" => "9",
65
+ "oct" => "10",
66
+ "nov" => "11",
67
+ "dec" => "12",
68
+
69
+ "sun" => "0",
70
+ "mon" => "1",
71
+ "tue" => "2",
72
+ "wed" => "3",
73
+ "thu" => "4",
74
+ "fri" => "5",
75
+ "sat" => "6"
76
+ }
77
+
78
+ def initialize(source,time_source = Time)
79
+ @source = interpret_vixieisms(source)
80
+ @time_source = time_source
81
+ validate_source
82
+ end
83
+
84
+ def interpret_vixieisms(spec)
85
+ case spec
86
+ when '@reboot'
87
+ raise ArgumentError, "Can't predict last/next run of @reboot"
88
+ when '@yearly', '@annually'
89
+ '0 0 1 1 *'
90
+ when '@monthly'
91
+ '0 0 1 * *'
92
+ when '@weekly'
93
+ '0 0 * * 0'
94
+ when '@daily', '@midnight'
95
+ '0 0 * * *'
96
+ when '@hourly'
97
+ '0 * * * *'
98
+ else
99
+ spec
100
+ end
101
+ end
102
+
103
+
104
+ # returns the next occurence after the given date
105
+ def next(now = @time_source.now, num = 1)
106
+ t = InternalTime.new(now, @time_source)
107
+
108
+ unless time_specs[:month][0].include?(t.month)
109
+ nudge_month(t)
110
+ t.day = 0
111
+ end
112
+
113
+ unless interpolate_weekdays(t.year, t.month)[0].include?(t.day)
114
+ nudge_date(t)
115
+ t.hour = -1
116
+ end
117
+
118
+ unless time_specs[:hour][0].include?(t.hour)
119
+ nudge_hour(t)
120
+ t.min = -1
121
+ end
122
+
123
+ # always nudge the minute
124
+ nudge_minute(t)
125
+ t = t.to_time
126
+ if num > 1
127
+ recursive_calculate(:next,t,num)
128
+ else
129
+ t
130
+ end
131
+ end
132
+
133
+ # returns the last occurence before the given date
134
+ def last(now = @time_source.now, num=1)
135
+ t = InternalTime.new(now,@time_source)
136
+
137
+ unless time_specs[:month][0].include?(t.month)
138
+ nudge_month(t, :last)
139
+ t.day = 32
140
+ end
141
+
142
+ if t.day == 32 || !interpolate_weekdays(t.year, t.month)[0].include?(t.day)
143
+ nudge_date(t, :last)
144
+ t.hour = 24
145
+ end
146
+
147
+ unless time_specs[:hour][0].include?(t.hour)
148
+ nudge_hour(t, :last)
149
+ t.min = 60
150
+ end
151
+
152
+ # always nudge the minute
153
+ nudge_minute(t, :last)
154
+ t = t.to_time
155
+ if num > 1
156
+ recursive_calculate(:last,t,num)
157
+ else
158
+ t
159
+ end
160
+ end
161
+
162
+
163
+ SUBELEMENT_REGEX = %r{^(\d+)(-(\d+)(/(\d+))?)?$}
164
+ def parse_element(elem, allowed_range)
165
+ values = elem.split(',').map do |subel|
166
+ if subel =~ /^\*/
167
+ step = subel.length > 1 ? subel[2..-1].to_i : 1
168
+ stepped_range(allowed_range, step)
169
+ else
170
+ if SUBELEMENT_REGEX === subel
171
+ if $5 # with range
172
+ stepped_range($1.to_i..$3.to_i, $5.to_i)
173
+ elsif $3 # range without step
174
+ stepped_range($1.to_i..$3.to_i, 1)
175
+ else # just a numeric
176
+ [$1.to_i]
177
+ end
178
+ else
179
+ raise ArgumentError, "Bad Vixie-style specification #{subel}"
180
+ end
181
+ end
182
+ end.flatten.sort
183
+
184
+ [Set.new(values), values, elem]
185
+ end
186
+
187
+
188
+ protected
189
+
190
+ def recursive_calculate(meth,time,num)
191
+ array = [time]
192
+ num.-(1).times do |num|
193
+ array << self.send(meth, array.last)
194
+ end
195
+ array
196
+ end
197
+
198
+ # returns a list of days which do both match time_spec[:dom] or time_spec[:dow]
199
+ def interpolate_weekdays(year, month)
200
+ @_interpolate_weekdays_cache ||= {}
201
+ @_interpolate_weekdays_cache["#{year}-#{month}"] ||= interpolate_weekdays_without_cache(year, month)
202
+ end
203
+
204
+ def interpolate_weekdays_without_cache(year, month)
205
+ t = Date.new(year, month, 1)
206
+ valid_mday, _, mday_field = time_specs[:dom]
207
+ valid_wday, _, wday_field = time_specs[:dow]
208
+
209
+ # Careful, if both DOW and DOM fields are non-wildcard,
210
+ # then we only need to match *one* for cron to run the job:
211
+ if not (mday_field == '*' and wday_field == '*')
212
+ valid_mday = [] if mday_field == '*'
213
+ valid_wday = [] if wday_field == '*'
214
+ end
215
+ # Careful: crontabs may use either 0 or 7 for Sunday:
216
+ valid_wday << 0 if valid_wday.include?(7)
217
+
218
+ result = []
219
+ while t.month == month
220
+ result << t.mday if valid_mday.include?(t.mday) || valid_wday.include?(t.wday)
221
+ t = t.succ
222
+ end
223
+
224
+ [Set.new(result), result]
225
+ end
226
+
227
+ def nudge_year(t, dir = :next)
228
+ t.year = t.year + (dir == :next ? 1 : -1)
229
+ end
230
+
231
+ def nudge_month(t, dir = :next)
232
+ spec = time_specs[:month][1]
233
+ next_value = find_best_next(t.month, spec, dir)
234
+ t.month = next_value || (dir == :next ? spec.first : spec.last)
235
+
236
+ nudge_year(t, dir) if next_value.nil?
237
+
238
+ # we changed the month, so its likely that the date is incorrect now
239
+ valid_days = interpolate_weekdays(t.year, t.month)[1]
240
+ t.day = dir == :next ? valid_days.first : valid_days.last
241
+ end
242
+
243
+ def date_valid?(t, dir = :next)
244
+ interpolate_weekdays(t.year, t.month)[0].include?(t.day)
245
+ end
246
+
247
+ def nudge_date(t, dir = :next, can_nudge_month = true)
248
+ spec = interpolate_weekdays(t.year, t.month)[1]
249
+ next_value = find_best_next(t.day, spec, dir)
250
+ t.day = next_value || (dir == :next ? spec.first : spec.last)
251
+
252
+ nudge_month(t, dir) if next_value.nil? && can_nudge_month
253
+ end
254
+
255
+ def nudge_hour(t, dir = :next)
256
+ spec = time_specs[:hour][1]
257
+ next_value = find_best_next(t.hour, spec, dir)
258
+ t.hour = next_value || (dir == :next ? spec.first : spec.last)
259
+
260
+ nudge_date(t, dir) if next_value.nil?
261
+ end
262
+
263
+ def nudge_minute(t, dir = :next)
264
+ spec = time_specs[:minute][1]
265
+ next_value = find_best_next(t.min, spec, dir)
266
+ t.min = next_value || (dir == :next ? spec.first : spec.last)
267
+
268
+ nudge_hour(t, dir) if next_value.nil?
269
+ end
270
+
271
+ def time_specs
272
+ @time_specs ||= begin
273
+ # tokens now contains the 5 fields
274
+ tokens = substitute_parse_symbols(@source).split(/\s+/)
275
+ {
276
+ :minute => parse_element(tokens[0], 0..59), #minute
277
+ :hour => parse_element(tokens[1], 0..23), #hour
278
+ :dom => parse_element(tokens[2], 1..31), #DOM
279
+ :month => parse_element(tokens[3], 1..12), #mon
280
+ :dow => parse_element(tokens[4], 0..6) #DOW
281
+ }
282
+ end
283
+ end
284
+
285
+ def substitute_parse_symbols(str)
286
+ SYMBOLS.inject(str.downcase) do |s, (symbol, replacement)|
287
+ s.gsub(symbol, replacement)
288
+ end
289
+ end
290
+
291
+
292
+ def stepped_range(rng, step = 1)
293
+ len = rng.last - rng.first
294
+
295
+ num = len.div(step)
296
+ result = (0..num).map { |i| rng.first + step * i }
297
+
298
+ result.pop if result[-1] == rng.last and rng.exclude_end?
299
+ result
300
+ end
301
+
302
+
303
+ # returns the smallest element from allowed which is greater than current
304
+ # returns nil if no matching value was found
305
+ def find_best_next(current, allowed, dir)
306
+ if dir == :next
307
+ allowed.sort.find { |val| val > current }
308
+ else
309
+ allowed.sort.reverse.find { |val| val < current }
310
+ end
311
+ end
312
+
313
+ def validate_source
314
+ unless @source.respond_to?(:split)
315
+ raise ArgumentError, 'not a valid cronline'
316
+ end
317
+ source_length = @source.split(/\s+/).length
318
+ unless source_length >= 5 && source_length <= 6
319
+ raise ArgumentError, 'not a valid cronline'
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Scheduled
3
+ VERSION = "0.1.0"
4
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scheduled
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam Daniels
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-06-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubygems-tasks
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.2'
41
+ description:
42
+ email: adam@mediadrive.ca
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/scheduled.rb
49
+ - lib/scheduled/cron_parser.rb
50
+ - lib/scheduled/version.rb
51
+ homepage: https://github.com/adam12/scheduled
52
+ licenses:
53
+ - MIT
54
+ metadata: {}
55
+ post_install_message:
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubyforge_project:
71
+ rubygems_version: 2.6.12
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: A very lightweight clock process with minimal dependencies and no magic.
75
+ test_files: []