delayed_cron_job 0.5.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.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ ODRhZWVmNmIyMGVjMzEyOTIzZWVmNzUzMWYzNjZjMTZkNTM3YmJkOA==
5
+ data.tar.gz: !binary |-
6
+ ZTQ5MDhjZTE1NjMyNjcxZTg3Yzk1YjA3OGJjYTgwN2M0OWU4YzJmYQ==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ ZmEwOGJjMGU3ZWE4NmU2ZDQ0MGQ4Y2I2NWFiOThkMTgwMWUwZDllNzE0NWQ0
10
+ MmU4YTczZTM1OGUwZmMwOWZkZGZhZDViY2M5YjA2YmEzODJjMmNkMzMwY2Iz
11
+ M2ZkOGQyN2M1ZDRmZWY0ZTJmZjExZjE5NmZlODRiNTIwNzMzNWQ=
12
+ data.tar.gz: !binary |-
13
+ YTY4NGZiNjE1Njg1NGViZTU4NjkxZTIyNzE4OTYzYjFkMGJjMjc4NzMzNzJj
14
+ ODA0NDg3NjYzM2EzZmYxNjhiMDdiOTE4ODAyYjY3Y2Q4YTFjMGE2NzQ3ZmNm
15
+ ZDA0MGQxN2MxMDVkZmU0ZWEzNjBjNjU0NDk0YzQ3MGEwZDZkMTc=
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --warnings
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in delayed_cron_job.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'pry-debugger'
8
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Pascal Zumkehr
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ 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 OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Delayed::Cron::Job
2
+
3
+ Delayed::Cron::Job is an extension to Delayed::Job that allows you to set
4
+ cron expressions for your jobs to run repeatedly.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ gem 'delayed_cron_job'
11
+
12
+ And then execute:
13
+
14
+ $ bundle
15
+
16
+ If you are using `delayed_job_active_record`, generate a migration (after the
17
+ original delayed job migration) to add the `cron` column to the `delayed_jobs`
18
+ table:
19
+
20
+ $ rails generate delayed_jobs:cron
21
+ $ rake db:migrate
22
+
23
+ There are no additional steps for `delayed_job_mongoid`.
24
+
25
+ ## Usage
26
+
27
+ When enqueuing a job, simply pass the `cron` option, e.g.:
28
+
29
+ Delayed::Job.enqueue(MyRepeatedJob.new, cron: '15 */6 * * 1-5')
30
+
31
+ Any crontab compatible cron expressions are supported (see `man 5 crontab`).
32
+ The credits for the `Cronline` class used go to
33
+ [rufus-scheduler](https://github.com/jmettraux/rufus-scheduler).
34
+
35
+ ## Details
36
+
37
+ The initial `run_at` value is computed during the `#enqueue` method call.
38
+ If you create `Delayed::Job` database entries directly, make sure to set
39
+ `run_at` accordingly.
40
+
41
+ You may use the `id` of the `Delayed::Job` as returned by the `#enqueue` method
42
+ to reference and/or remove the scheduled job in the future.
43
+
44
+ The subsequent run of a job is only scheduled after the current run has
45
+ terminated. If a single run takes longer than the given execution interval,
46
+ some runs may be skipped. E.g., if a run takes five minutes, but the job is
47
+ scheduled to be executed every second minute, it will actually only execute
48
+ every sixth minute: With a cron of `*/2 * * * *`, if the current run starts at
49
+ `:00` and finishes at `:05`, then the next scheduled execution time is at `:06`,
50
+ and so on.
51
+
52
+ If you do not want longer running jobs to skip executions, simply create a
53
+ lightweight master job that enqueues the actual workload as separate jobs.
54
+ Of course you have to make sure to start enough workers to handle all these
55
+ jobs.
56
+
57
+ ## Contributing
58
+
59
+ 1. Fork it ( https://github.com/codez/delayed_cron_job/fork )
60
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
61
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
62
+ 4. Push to the branch (`git push origin my-new-feature`)
63
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'delayed_cron_job/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "delayed_cron_job"
8
+ spec.version = DelayedCronJob::VERSION
9
+ spec.authors = ["Pascal Zumkehr"]
10
+ spec.email = ["spam@codez.ch"]
11
+ spec.summary = %q{An extension to Delayed::Job that allows you to set
12
+ cron expressions for your jobs to run regularly.}
13
+ spec.description = %q{Delayed Cron Job is an extension to Delayed::Job
14
+ that allows you to set cron expressions for your
15
+ jobs to run regularly.}
16
+ spec.homepage = "https://github.com/codez/delayed_cron_job"
17
+ spec.license = "MIT"
18
+
19
+ spec.files = `git ls-files -z`.split("\x0")
20
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
21
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_dependency "delayed_job"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.6"
27
+ spec.add_development_dependency "rake"
28
+ spec.add_development_dependency "rspec"
29
+ spec.add_development_dependency "sqlite3"
30
+ spec.add_development_dependency "delayed_job_active_record"
31
+ end
@@ -0,0 +1,15 @@
1
+ require 'delayed_job'
2
+ require 'english'
3
+ require 'delayed_cron_job/cronline'
4
+ require 'delayed_cron_job/plugin'
5
+ require 'delayed_cron_job/version'
6
+
7
+ module DelayedCronJob
8
+
9
+ end
10
+
11
+ if defined?(Delayed::Backend::Mongoid)
12
+ Delayed::Backend::Mongoid::Job.field :cron, :type => String
13
+ end
14
+
15
+ DelayedCronJob::Plugin.callback_block.call(Delayed::Worker.lifecycle)
@@ -0,0 +1,465 @@
1
+ #--
2
+ # Copyright (c) 2006-2014, 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
+
26
+ module DelayedCronJob
27
+
28
+ #
29
+ # A 'cron line' is a line in the sense of a crontab
30
+ # (man 5 crontab) file line.
31
+ #
32
+ class Cronline
33
+
34
+ # The string used for creating this cronline instance.
35
+ #
36
+ attr_reader :original
37
+
38
+ attr_reader :seconds
39
+ attr_reader :minutes
40
+ attr_reader :hours
41
+ attr_reader :days
42
+ attr_reader :months
43
+ attr_reader :weekdays
44
+ attr_reader :monthdays
45
+ attr_reader :timezone
46
+
47
+ def initialize(line)
48
+
49
+ raise ArgumentError.new(
50
+ "not a string: #{line.inspect}"
51
+ ) unless line.is_a?(String)
52
+
53
+ @original = line
54
+
55
+ items = line.split
56
+
57
+ @timezone = (TZInfo::Timezone.get(items.last) rescue nil)
58
+ items.pop if @timezone
59
+
60
+ raise ArgumentError.new(
61
+ "not a valid cronline : '#{line}'"
62
+ ) unless items.length == 5 or items.length == 6
63
+
64
+ offset = items.length - 5
65
+
66
+ @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ]
67
+ @minutes = parse_item(items[0 + offset], 0, 59)
68
+ @hours = parse_item(items[1 + offset], 0, 24)
69
+ @days = parse_item(items[2 + offset], 1, 31)
70
+ @months = parse_item(items[3 + offset], 1, 12)
71
+ @weekdays, @monthdays = parse_weekdays(items[4 + offset])
72
+
73
+ [ @seconds, @minutes, @hours, @months ].each do |es|
74
+
75
+ raise ArgumentError.new(
76
+ "invalid cronline: '#{line}'"
77
+ ) if es && es.find { |e| ! e.is_a?(Fixnum) }
78
+ end
79
+ end
80
+
81
+ # Returns true if the given time matches this cron line.
82
+ #
83
+ def matches?(time)
84
+
85
+ time = Time.at(time) unless time.kind_of?(Time)
86
+
87
+ time = @timezone.utc_to_local(time.getutc) if @timezone
88
+
89
+ return false unless sub_match?(time, :sec, @seconds)
90
+ return false unless sub_match?(time, :min, @minutes)
91
+ return false unless sub_match?(time, :hour, @hours)
92
+ return false unless date_match?(time)
93
+ true
94
+ end
95
+
96
+ # Returns the next time that this cron line is supposed to 'fire'
97
+ #
98
+ # This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
99
+ # (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
100
+ #
101
+ # This method accepts an optional Time parameter. It's the starting point
102
+ # for the 'search'. By default, it's Time.now
103
+ #
104
+ # Note that the time instance returned will be in the same time zone that
105
+ # the given start point Time (thus a result in the local time zone will
106
+ # be passed if no start time is specified (search start time set to
107
+ # Time.now))
108
+ #
109
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
110
+ # Time.mktime(2008, 10, 24, 7, 29))
111
+ # #=> Fri Oct 24 07:30:00 -0500 2008
112
+ #
113
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
114
+ # Time.utc(2008, 10, 24, 7, 29))
115
+ # #=> Fri Oct 24 07:30:00 UTC 2008
116
+ #
117
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
118
+ # Time.utc(2008, 10, 24, 7, 29)).localtime
119
+ # #=> Fri Oct 24 02:30:00 -0500 2008
120
+ #
121
+ # (Thanks to K Liu for the note and the examples)
122
+ #
123
+ def next_time(from=Time.now)
124
+
125
+ time = local_time(from)
126
+ time = round_to_seconds(time)
127
+
128
+ # start at the next second
129
+ time = time + 1
130
+
131
+ loop do
132
+ unless date_match?(time)
133
+ dst = time.isdst
134
+ time += (24 - time.hour) * 3600 - time.min * 60 - time.sec
135
+ time -= 3600 if time.isdst != dst # not necessary for winter, but...
136
+ next
137
+ end
138
+ unless sub_match?(time, :hour, @hours)
139
+ time += (60 - time.min) * 60 - time.sec; next
140
+ end
141
+ unless sub_match?(time, :min, @minutes)
142
+ time += 60 - time.sec; next
143
+ end
144
+ unless sub_match?(time, :sec, @seconds)
145
+ time += 1; next
146
+ end
147
+
148
+ break
149
+ end
150
+
151
+ global_time(time, from.utc?)
152
+
153
+ rescue TZInfo::PeriodNotFound
154
+
155
+ next_time(from + 3600)
156
+ end
157
+
158
+ # Returns the previous time the cronline matched. It's like next_time, but
159
+ # for the past.
160
+ #
161
+ def previous_time(from=Time.now)
162
+
163
+ time = local_time(from)
164
+ time = round_to_seconds(time)
165
+
166
+ # start at the previous second
167
+ time = time - 1
168
+
169
+ loop do
170
+ unless date_match?(time)
171
+ time -= time.hour * 3600 + time.min * 60 + time.sec + 1; next
172
+ end
173
+ unless sub_match?(time, :hour, @hours)
174
+ time -= time.min * 60 + time.sec + 1; next
175
+ end
176
+ unless sub_match?(time, :min, @minutes)
177
+ time -= time.sec + 1; next
178
+ end
179
+ unless sub_match?(time, :sec, @seconds)
180
+ time -= 1; next
181
+ end
182
+
183
+ break
184
+ end
185
+
186
+ global_time(time, from.utc?)
187
+
188
+ rescue TZInfo::PeriodNotFound
189
+
190
+ previous_time(time)
191
+ end
192
+
193
+ # Returns an array of 6 arrays (seconds, minutes, hours, days,
194
+ # months, weekdays).
195
+ # This method is used by the cronline unit tests.
196
+ #
197
+ def to_array
198
+
199
+ [
200
+ @seconds,
201
+ @minutes,
202
+ @hours,
203
+ @days,
204
+ @months,
205
+ @weekdays,
206
+ @monthdays,
207
+ @timezone ? @timezone.name : nil
208
+ ]
209
+ end
210
+
211
+ # Returns a quickly computed approximation of the frequency for this
212
+ # cron line.
213
+ #
214
+ # #brute_frequency, on the other hand, will compute the frequency by
215
+ # examining a whole, that can take more than seconds for a seconds
216
+ # level cron...
217
+ #
218
+ def frequency
219
+
220
+ return brute_frequency unless @seconds && @seconds.length > 1
221
+
222
+ delta = 60
223
+ prev = @seconds[0]
224
+
225
+ @seconds[1..-1].each do |sec|
226
+ d = sec - prev
227
+ delta = d if d < delta
228
+ end
229
+
230
+ delta
231
+ end
232
+
233
+ # Returns the shortest delta between two potential occurences of the
234
+ # schedule described by this cronline.
235
+ #
236
+ # .
237
+ #
238
+ # For a simple cronline like "*/5 * * * *", obviously the frequency is
239
+ # five minutes. Why does this method look at a whole year of #next_time ?
240
+ #
241
+ # Consider "* * * * sun#2,sun#3", the computed frequency is 1 week
242
+ # (the shortest delta is the one between the second sunday and the third
243
+ # sunday). This method takes no chance and runs next_time for the span
244
+ # of a whole year and keeps the shortest.
245
+ #
246
+ # Of course, this method can get VERY slow if you call on it a second-
247
+ # based cronline...
248
+ #
249
+ # Since it's a rarely used method, I haven't taken the time to make it
250
+ # smarter/faster.
251
+ #
252
+ # One obvious improvement would be to cache the result once computed...
253
+ #
254
+ # See https://github.com/jmettraux/rufus-scheduler/issues/89
255
+ # for a discussion about this method.
256
+ #
257
+ def brute_frequency
258
+
259
+ delta = 366 * DAY_S
260
+
261
+ t0 = previous_time(Time.local(2000, 1, 1))
262
+
263
+ loop do
264
+
265
+ break if delta <= 1
266
+ break if delta <= 60 && @seconds && @seconds.size == 1
267
+
268
+ t1 = next_time(t0)
269
+ d = t1 - t0
270
+ delta = d if d < delta
271
+
272
+ break if @months == nil && t1.month == 2
273
+ break if t1.year == 2001
274
+
275
+ t0 = t1
276
+ end
277
+
278
+ delta
279
+ end
280
+
281
+ protected
282
+
283
+ WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
284
+ DAY_S = 24 * 3600
285
+ WEEK_S = 7 * DAY_S
286
+
287
+ def parse_weekdays(item)
288
+
289
+ return nil if item == '*'
290
+
291
+ items = item.downcase.split(',')
292
+
293
+ weekdays = nil
294
+ monthdays = nil
295
+
296
+ items.each do |it|
297
+
298
+ if m = it.match(/^(.+)#(l|-?[12345])$/)
299
+
300
+ raise ArgumentError.new(
301
+ "ranges are not supported for monthdays (#{it})"
302
+ ) if m[1].index('-')
303
+
304
+ expr = it.gsub(/#l/, '#-1')
305
+
306
+ (monthdays ||= []) << expr
307
+
308
+ else
309
+
310
+ expr = it.dup
311
+ WEEKDAYS.each_with_index { |a, i| expr.gsub!(/#{a}/, i.to_s) }
312
+
313
+ raise ArgumentError.new(
314
+ "invalid weekday expression (#{it})"
315
+ ) if expr !~ /^0*[0-7](-0*[0-7])?$/
316
+
317
+ its = expr.index('-') ? parse_range(expr, 0, 7) : [ Integer(expr) ]
318
+ its = its.collect { |i| i == 7 ? 0 : i }
319
+
320
+ (weekdays ||= []).concat(its)
321
+ end
322
+ end
323
+
324
+ weekdays = weekdays.uniq if weekdays
325
+
326
+ [ weekdays, monthdays ]
327
+ end
328
+
329
+ def parse_item(item, min, max)
330
+
331
+ return nil if item == '*'
332
+
333
+ r = item.split(',').map { |i| parse_range(i.strip, min, max) }.flatten
334
+
335
+ raise ArgumentError.new(
336
+ "found duplicates in #{item.inspect}"
337
+ ) if r.uniq.size < r.size
338
+
339
+ r
340
+ end
341
+
342
+ RANGE_REGEX = /^(\*|\d{1,2})(?:-(\d{1,2}))?(?:\/(\d{1,2}))?$/
343
+
344
+ def parse_range(item, min, max)
345
+
346
+ return %w[ L ] if item == 'L'
347
+
348
+ item = '*' + item if item.match(/^\//)
349
+
350
+ m = item.match(RANGE_REGEX)
351
+
352
+ raise ArgumentError.new(
353
+ "cannot parse #{item.inspect}"
354
+ ) unless m
355
+
356
+ sta = m[1]
357
+ sta = sta == '*' ? min : sta.to_i
358
+
359
+ edn = m[2]
360
+ edn = edn ? edn.to_i : sta
361
+ edn = max if m[1] == '*'
362
+
363
+ inc = m[3]
364
+ inc = inc ? inc.to_i : 1
365
+
366
+ raise ArgumentError.new(
367
+ "#{item.inspect} is not in range #{min}..#{max}"
368
+ ) if sta < min || edn > max
369
+
370
+ r = []
371
+ val = sta
372
+
373
+ loop do
374
+ v = val
375
+ v = 0 if max == 24 && v == 24
376
+ r << v
377
+ break if inc == 1 && val == edn
378
+ val += inc
379
+ break if inc > 1 && val > edn
380
+ val = min if val > max
381
+ end
382
+
383
+ r.uniq
384
+ end
385
+
386
+ def sub_match?(time, accessor, values)
387
+
388
+ value = time.send(accessor)
389
+
390
+ return true if values.nil?
391
+ return true if values.include?('L') && (time + DAY_S).day == 1
392
+
393
+ return true if value == 0 && accessor == :hour && values.include?(24)
394
+
395
+ values.include?(value)
396
+ end
397
+
398
+ def monthday_match?(date, values)
399
+
400
+ return true if values.nil?
401
+
402
+ today_values = monthdays(date)
403
+
404
+ (today_values & values).any?
405
+ end
406
+
407
+ def date_match?(date)
408
+
409
+ return false unless sub_match?(date, :day, @days)
410
+ return false unless sub_match?(date, :month, @months)
411
+ return false unless sub_match?(date, :wday, @weekdays)
412
+ return false unless monthday_match?(date, @monthdays)
413
+ true
414
+ end
415
+
416
+ def monthdays(date)
417
+
418
+ pos = 1
419
+ d = date.dup
420
+
421
+ loop do
422
+ d = d - WEEK_S
423
+ break if d.month != date.month
424
+ pos = pos + 1
425
+ end
426
+
427
+ neg = -1
428
+ d = date.dup
429
+
430
+ loop do
431
+ d = d + WEEK_S
432
+ break if d.month != date.month
433
+ neg = neg - 1
434
+ end
435
+
436
+ [ "#{WEEKDAYS[date.wday]}##{pos}", "#{WEEKDAYS[date.wday]}##{neg}" ]
437
+ end
438
+
439
+ def local_time(time)
440
+
441
+ @timezone ? @timezone.utc_to_local(time.getutc) : time
442
+ end
443
+
444
+ def global_time(time, from_in_utc)
445
+
446
+ if @timezone
447
+ time =
448
+ begin
449
+ @timezone.local_to_utc(time)
450
+ rescue TZInfo::AmbiguousTime
451
+ @timezone.local_to_utc(time, time.isdst)
452
+ end
453
+ time = time.getlocal unless from_in_utc
454
+ end
455
+
456
+ time
457
+ end
458
+
459
+ def round_to_seconds(time)
460
+
461
+ # Ruby 1.8 doesn't have #round
462
+ time.respond_to?(:round) ? time.round : time - time.usec * 1e-6
463
+ end
464
+ end
465
+ end
@@ -0,0 +1,58 @@
1
+ module DelayedCronJob
2
+ class Plugin < Delayed::Plugin
3
+
4
+ class << self
5
+ def next_run_at(job)
6
+ job.run_at = Cronline.new(job.cron).next_time(Delayed::Job.db_time_now)
7
+ end
8
+
9
+ def cron?(job)
10
+ job.cron.present?
11
+ end
12
+ end
13
+
14
+ callbacks do |lifecycle|
15
+ # Calculate the next run_at based on the cron attribute before enqueue.
16
+ lifecycle.before(:enqueue) do |job|
17
+ next_run_at(job) if cron?(job)
18
+ end
19
+
20
+ # Prevent rescheduling of failed jobs as this is already done
21
+ # after perform.
22
+ lifecycle.around(:error) do |worker, job, &block|
23
+ if cron?(job)
24
+ job.last_error = "#{$ERROR_INFO.message}\n#{$ERROR_INFO.backtrace.join("\n")}"
25
+ worker.job_say(job,
26
+ "FAILED with #{$ERROR_INFO.class.name}: #{$ERROR_INFO.message}",
27
+ Logger::ERROR)
28
+ job.destroy
29
+ else
30
+ # No cron job - proceed as normal
31
+ block.call(worker, job)
32
+ end
33
+ end
34
+
35
+ # Reset the last_error to have the correct status of the last run.
36
+ lifecycle.before(:perform) do |worker, job|
37
+ if cron?(job)
38
+ job.last_error = nil
39
+ end
40
+ end
41
+
42
+ # Schedule the next run based on the cron attribute.
43
+ lifecycle.after(:perform) do |worker, job|
44
+ if cron?(job)
45
+ next_job = job.dup
46
+ next_job.id = job.id
47
+ next_job.created_at = job.created_at
48
+ next_job.locked_at = nil
49
+ next_job.locked_by = nil
50
+ next_job.attempts += 1
51
+ next_run_at(next_job)
52
+ next_job.save!
53
+ end
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,3 @@
1
+ module DelayedCronJob
2
+ VERSION = "0.5.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ require 'generators/delayed_job/delayed_job_generator'
2
+ require 'generators/delayed_job/next_migration_version'
3
+ require 'rails/generators/migration'
4
+ require 'rails/generators/active_record'
5
+
6
+ # Extend the DelayedJobGenerator so that it creates an AR migration
7
+ module DelayedJob
8
+ class CronGenerator < ::DelayedJobGenerator
9
+ include Rails::Generators::Migration
10
+ extend NextMigrationVersion
11
+
12
+ self.source_paths << File.join(File.dirname(__FILE__), 'templates')
13
+
14
+ def create_migration_file
15
+ migration_template('cron_migration.rb', 'db/migrate/add_cron_to_delayed_jobs.rb')
16
+ end
17
+
18
+ def self.next_migration_number(dirname)
19
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ class AddCronToDelayedJobs < ActiveRecord::Migration
2
+ def self.up
3
+ add_column :delayed_jobs, :cron, :string
4
+ end
5
+
6
+ def self.down
7
+ remove_column :delayed_jobs, :cron
8
+ end
9
+ end
@@ -0,0 +1,163 @@
1
+
2
+
3
+ describe DelayedCronJob do
4
+
5
+ class TestJob
6
+ def perform; end
7
+ end
8
+
9
+ before { Delayed::Job.delete_all }
10
+
11
+ let(:cron) { '5 1 * * *' }
12
+ let(:handler) { TestJob.new }
13
+ let(:job) { Delayed::Job.enqueue(handler, cron: cron) }
14
+ let(:worker) { Delayed::Worker.new }
15
+ let(:now) { Delayed::Job.db_time_now }
16
+ let(:next_run) do
17
+ run = now.hour * 60 + now.min >= 65 ? now + 1.day : now
18
+ Time.utc(run.year, run.month, run.day, 1, 5)
19
+ end
20
+
21
+ context 'with cron' do
22
+ it 'sets run_at on enqueue' do
23
+ expect { job }.to change { Delayed::Job.count }.by(1)
24
+ expect(job.run_at).to eq(next_run)
25
+ end
26
+
27
+ it 'enqueue fails with invalid cron' do
28
+ expect { Delayed::Job.enqueue(handler, cron: 'no valid cron') }.
29
+ to raise_error(ArgumentError)
30
+ end
31
+
32
+ it 'schedules a new job after success' do
33
+ job.update_column(:run_at, now)
34
+
35
+ worker.work_off
36
+
37
+ expect(Delayed::Job.count).to eq(1)
38
+ j = Delayed::Job.first
39
+ expect(j.id).to eq(job.id)
40
+ expect(j.cron).to eq(job.cron)
41
+ expect(j.run_at).to eq(next_run)
42
+ expect(j.attempts).to eq(1)
43
+ expect(j.last_error).to eq(nil)
44
+ expect(j.created_at).to eq(job.created_at)
45
+ end
46
+
47
+ it 'schedules a new job after failure' do
48
+ allow_any_instance_of(TestJob).to receive(:perform).and_raise('Fail!')
49
+ job.update(run_at: now)
50
+
51
+ worker.work_off
52
+
53
+ expect(Delayed::Job.count).to eq(1)
54
+ j = Delayed::Job.first
55
+ expect(j.id).to eq(job.id)
56
+ expect(j.cron).to eq(job.cron)
57
+ expect(j.run_at).to eq(next_run)
58
+ expect(j.last_error).to match('Fail!')
59
+ expect(j.created_at).to eq(job.created_at)
60
+ end
61
+
62
+ it 'schedules a new job after timeout' do
63
+ Delayed::Worker.max_run_time = 1.second
64
+ job.update_column(:run_at, now)
65
+ allow_any_instance_of(TestJob).to receive(:perform) { sleep 2 }
66
+
67
+ worker.work_off
68
+
69
+ expect(Delayed::Job.count).to eq(1)
70
+ j = Delayed::Job.first
71
+ expect(j.id).to eq(job.id)
72
+ expect(j.cron).to eq(job.cron)
73
+ expect(j.run_at).to eq(next_run)
74
+ expect(j.attempts).to eq(1)
75
+ expect(j.last_error).to match("execution expired")
76
+ end
77
+
78
+ it 'schedules new job after deserialization error' do
79
+ Delayed::Worker.max_run_time = 1.second
80
+ job.update_column(:run_at, now)
81
+ allow_any_instance_of(TestJob).to receive(:perform).and_raise(Delayed::DeserializationError)
82
+
83
+ worker.work_off
84
+
85
+ expect(Delayed::Job.count).to eq(1)
86
+ j = Delayed::Job.first
87
+ expect(j.last_error).to match("Delayed::DeserializationError")
88
+ end
89
+
90
+ it 'has empty last_error after success' do
91
+ job.update(run_at: now, last_error: 'Last error')
92
+
93
+ worker.work_off
94
+
95
+ j = Delayed::Job.first
96
+ expect(j.last_error).to eq(nil)
97
+ end
98
+
99
+ it 'has correct last_error after success' do
100
+ allow_any_instance_of(TestJob).to receive(:perform).and_raise('Fail!')
101
+ job.update(run_at: now, last_error: 'Last error')
102
+
103
+ worker.work_off
104
+
105
+ j = Delayed::Job.first
106
+ expect(j.last_error).to match('Fail!')
107
+ end
108
+
109
+ it 'uses correct db time for next run' do
110
+ if Time.now != now
111
+ job = Delayed::Job.enqueue(handler, cron: '* * * * *')
112
+ run = now.hour == 23 && now.min == 59 ? now + 1.day : now
113
+ hour = now.min == 59 ? (now.hour + 1) % 24 : now.hour
114
+ run_at = Time.utc(run.year, run.month, run.day, hour, (now.min + 1) % 60)
115
+ expect(job.run_at).to eq(run_at)
116
+ else
117
+ pending "This test only makes sense in non-UTC time zone"
118
+ end
119
+ end
120
+
121
+ it 'increases attempts on each run' do
122
+ job.update(run_at: now, attempts: 3)
123
+
124
+ worker.work_off
125
+
126
+ j = Delayed::Job.first
127
+ expect(j.attempts).to eq(4)
128
+ end
129
+
130
+ it 'is not stopped by max attempts' do
131
+ job.update(run_at: now, attempts: Delayed::Worker.max_attempts + 1)
132
+
133
+ worker.work_off
134
+
135
+ expect(Delayed::Job.count).to eq(1)
136
+ j = Delayed::Job.first
137
+ expect(j.attempts).to eq(job.attempts + 1)
138
+ end
139
+ end
140
+
141
+ context 'without cron' do
142
+ it 'reschedules the original job after a single failure' do
143
+ allow_any_instance_of(TestJob).to receive(:perform).and_raise('Fail!')
144
+ job = Delayed::Job.enqueue(handler)
145
+
146
+ worker.work_off
147
+
148
+ expect(Delayed::Job.count).to eq(1)
149
+ j = Delayed::Job.first
150
+ expect(j.id).to eq(job.id)
151
+ expect(j.cron).to eq(nil)
152
+ expect(j.last_error).to match('Fail!')
153
+ end
154
+
155
+ it 'does not reschedule a job after a successful run' do
156
+ job = Delayed::Job.enqueue(handler)
157
+
158
+ worker.work_off
159
+
160
+ expect(Delayed::Job.count).to eq(0)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,109 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # The generated `.rspec` file contains `--require spec_helper` which will cause this
4
+ # file to always be loaded, without a need to explicitly require it in any files.
5
+ #
6
+ # Given that it is always loaded, you are encouraged to keep this file as
7
+ # light-weight as possible. Requiring heavyweight dependencies from this file
8
+ # will add to the boot time of your test suite on EVERY test run, even for an
9
+ # individual file that may not need all of that loaded. Instead, make a
10
+ # separate helper file that requires this one and then use it only in the specs
11
+ # that actually need it.
12
+ #
13
+ # The `.rspec` file also contains a few flags that are not defaults but that
14
+ # users commonly want.
15
+ #
16
+
17
+ require 'delayed_job_active_record'
18
+ require 'delayed_cron_job'
19
+ require 'pry'
20
+
21
+ Delayed::Worker.logger = Logger.new('/tmp/dj.log')
22
+ ENV['RAILS_ENV'] = 'test'
23
+
24
+ ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => ':memory:'
25
+ ActiveRecord::Base.logger = Delayed::Worker.logger
26
+ ActiveRecord::Migration.verbose = false
27
+
28
+ ActiveRecord::Schema.define do
29
+ create_table :delayed_jobs, :force => true do |t|
30
+ t.integer :priority, :default => 0
31
+ t.integer :attempts, :default => 0
32
+ t.text :handler
33
+ t.text :last_error
34
+ t.datetime :run_at
35
+ t.datetime :locked_at
36
+ t.datetime :failed_at
37
+ t.string :locked_by
38
+ t.string :queue
39
+ t.string :cron
40
+ t.timestamps
41
+ end
42
+
43
+ add_index :delayed_jobs, [:priority, :run_at], :name => 'delayed_jobs_priority'
44
+ end
45
+
46
+
47
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
48
+ RSpec.configure do |config|
49
+ # The settings below are suggested to provide a good initial experience
50
+ # with RSpec, but feel free to customize to your heart's content.
51
+ =begin
52
+ # These two settings work together to allow you to limit a spec run
53
+ # to individual examples or groups you care about by tagging them with
54
+ # `:focus` metadata. When nothing is tagged with `:focus`, all examples
55
+ # get run.
56
+ config.filter_run :focus
57
+ config.run_all_when_everything_filtered = true
58
+
59
+ # Many RSpec users commonly either run the entire suite or an individual
60
+ # file, and it's useful to allow more verbose output when running an
61
+ # individual spec file.
62
+ if config.files_to_run.one?
63
+ # Use the documentation formatter for detailed output,
64
+ # unless a formatter has already been configured
65
+ # (e.g. via a command-line flag).
66
+ config.default_formatter = 'doc'
67
+ end
68
+
69
+ # Print the 10 slowest examples and example groups at the
70
+ # end of the spec run, to help surface which specs are running
71
+ # particularly slow.
72
+ config.profile_examples = 10
73
+
74
+ # Run specs in random order to surface order dependencies. If you find an
75
+ # order dependency and want to debug it, you can fix the order by providing
76
+ # the seed, which is printed after each run.
77
+ # --seed 1234
78
+ config.order = :random
79
+
80
+ # Seed global randomization in this process using the `--seed` CLI option.
81
+ # Setting this allows you to use `--seed` to deterministically reproduce
82
+ # test failures related to randomization by passing the same `--seed` value
83
+ # as the one that triggered the failure.
84
+ Kernel.srand config.seed
85
+
86
+ # rspec-expectations config goes here. You can use an alternate
87
+ # assertion/expectation library such as wrong or the stdlib/minitest
88
+ # assertions if you prefer.
89
+ config.expect_with :rspec do |expectations|
90
+ # Enable only the newer, non-monkey-patching expect syntax.
91
+ # For more details, see:
92
+ # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
93
+ expectations.syntax = :expect
94
+ end
95
+
96
+ # rspec-mocks config goes here. You can use an alternate test double
97
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
98
+ config.mock_with :rspec do |mocks|
99
+ # Enable only the newer, non-monkey-patching expect syntax.
100
+ # For more details, see:
101
+ # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
102
+ mocks.syntax = :expect
103
+
104
+ # Prevents you from mocking or stubbing a method that does not exist on
105
+ # a real object. This is generally recommended.
106
+ mocks.verify_partial_doubles = true
107
+ end
108
+ =end
109
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: delayed_cron_job
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Pascal Zumkehr
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: delayed_job
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ! '>='
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ! '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ~>
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ! '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ! '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ! '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ! '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: delayed_job_active_record
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ! '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ! '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: ! "Delayed Cron Job is an extension to Delayed::Job\n that
98
+ allows you to set cron expressions for your\n jobs to run
99
+ regularly."
100
+ email:
101
+ - spam@codez.ch
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - .gitignore
107
+ - .rspec
108
+ - Gemfile
109
+ - LICENSE.txt
110
+ - README.md
111
+ - Rakefile
112
+ - delayed_cron_job.gemspec
113
+ - lib/delayed_cron_job.rb
114
+ - lib/delayed_cron_job/cronline.rb
115
+ - lib/delayed_cron_job/plugin.rb
116
+ - lib/delayed_cron_job/version.rb
117
+ - lib/generators/delayed_job/cron_generator.rb
118
+ - lib/generators/delayed_job/templates/cron_migration.rb
119
+ - spec/delayed_cron_job_spec.rb
120
+ - spec/spec_helper.rb
121
+ homepage: https://github.com/codez/delayed_cron_job
122
+ licenses:
123
+ - MIT
124
+ metadata: {}
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ! '>='
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubyforge_project:
141
+ rubygems_version: 2.2.2
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: An extension to Delayed::Job that allows you to set cron expressions for
145
+ your jobs to run regularly.
146
+ test_files:
147
+ - spec/delayed_cron_job_spec.rb
148
+ - spec/spec_helper.rb
149
+ has_rdoc: