rocketjob 5.2.0 → 5.4.0.beta1

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/rocket_job/batch.rb +1 -0
  4. data/lib/rocket_job/batch/io.rb +14 -19
  5. data/lib/rocket_job/batch/model.rb +2 -2
  6. data/lib/rocket_job/batch/tabular/input.rb +9 -5
  7. data/lib/rocket_job/batch/tabular/output.rb +9 -3
  8. data/lib/rocket_job/batch/throttle.rb +1 -1
  9. data/lib/rocket_job/batch/throttle_running_workers.rb +1 -1
  10. data/lib/rocket_job/batch/throttle_windows.rb +72 -0
  11. data/lib/rocket_job/batch/worker.rb +2 -8
  12. data/lib/rocket_job/event.rb +0 -2
  13. data/lib/rocket_job/extensions/mongoid/clients/options.rb +0 -2
  14. data/lib/rocket_job/extensions/mongoid/remove_warnings.rb +12 -0
  15. data/lib/rocket_job/jobs/copy_file_job.rb +1 -1
  16. data/lib/rocket_job/jobs/re_encrypt/relational_job.rb +2 -5
  17. data/lib/rocket_job/jobs/upload_file_job.rb +1 -1
  18. data/lib/rocket_job/plugins/cron.rb +6 -23
  19. data/lib/rocket_job/plugins/job/throttle.rb +1 -1
  20. data/lib/rocket_job/plugins/job/throttle_running_jobs.rb +1 -1
  21. data/lib/rocket_job/plugins/job/worker.rb +5 -4
  22. data/lib/rocket_job/plugins/processing_window.rb +7 -13
  23. data/lib/rocket_job/sliced.rb +91 -0
  24. data/lib/rocket_job/sliced/bzip2_output_slice.rb +43 -0
  25. data/lib/rocket_job/sliced/input.rb +3 -3
  26. data/lib/rocket_job/sliced/slice.rb +6 -0
  27. data/lib/rocket_job/sliced/slices.rb +6 -0
  28. data/lib/rocket_job/subscribers/server.rb +9 -3
  29. data/lib/rocket_job/supervisor.rb +3 -1
  30. data/lib/rocket_job/version.rb +1 -1
  31. data/lib/rocket_job/worker_pool.rb +1 -0
  32. data/lib/rocketjob.rb +7 -20
  33. metadata +27 -11
  34. data/lib/rocket_job/plugins/rufus/cron_line.rb +0 -520
  35. data/lib/rocket_job/plugins/rufus/zo_time.rb +0 -524
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "fugit"
2
3
 
3
4
  module RocketJob
4
5
  module Plugins
@@ -47,18 +48,14 @@ module RocketJob
47
48
 
48
49
  validates_presence_of :processing_schedule, :processing_duration
49
50
  validates_each :processing_schedule do |record, attr, value|
50
- begin
51
- RocketJob::Plugins::Rufus::CronLine.new(value)
52
- rescue ArgumentError => e
53
- record.errors.add(attr, e.message)
54
- end
51
+ record.errors.add(attr, "Invalid schedule: #{value.inspect}") unless Fugit::Cron.new(value)
55
52
  end
56
53
  end
57
54
 
58
55
  # Returns [true|false] whether this job is currently inside its processing window
59
56
  def rocket_job_processing_window_active?
60
- time = Time.now
61
- previous_time = rocket_job_processing_schedule.previous_time(time)
57
+ time = Time.now.utc
58
+ previous_time = Fugit::Cron.new(processing_schedule).previous_time(time).to_utc_time
62
59
  # Inside previous processing window?
63
60
  previous_time + processing_duration > time
64
61
  end
@@ -69,17 +66,14 @@ module RocketJob
69
66
  def rocket_job_processing_window_check
70
67
  return if rocket_job_processing_window_active?
71
68
 
72
- logger.warn("Processing window closed before job was processed. Job is re-scheduled to run at: #{rocket_job_processing_schedule.next_time}")
69
+ next_time = Fugit::Cron.new(processing_schedule).next_time.to_utc_time
70
+ logger.warn("Processing window closed before job was processed. Job is re-scheduled to run at: #{next_time}")
73
71
  self.worker_name ||= "inline"
74
72
  requeue!(worker_name)
75
73
  end
76
74
 
77
75
  def rocket_job_processing_window_set_run_at
78
- self.run_at = rocket_job_processing_schedule.next_time unless rocket_job_processing_window_active?
79
- end
80
-
81
- def rocket_job_processing_schedule
82
- RocketJob::Plugins::Rufus::CronLine.new(processing_schedule)
76
+ self.run_at = Fugit::Cron.new(processing_schedule).next_time.to_utc_time unless rocket_job_processing_window_active?
83
77
  end
84
78
  end
85
79
  end
@@ -0,0 +1,91 @@
1
+ module RocketJob
2
+ module Sliced
3
+ autoload :BZip2OutputSlice, "rocket_job/sliced/bzip2_output_slice"
4
+ autoload :CompressedSlice, "rocket_job/sliced/compressed_slice"
5
+ autoload :EncryptedSlice, "rocket_job/sliced/encrypted_slice"
6
+ autoload :Input, "rocket_job/sliced/input"
7
+ autoload :Output, "rocket_job/sliced/output"
8
+ autoload :Slice, "rocket_job/sliced/slice"
9
+ autoload :Slices, "rocket_job/sliced/slices"
10
+ autoload :Store, "rocket_job/sliced/store"
11
+
12
+ module Writer
13
+ autoload :Input, "rocket_job/sliced/writer/input"
14
+ autoload :Output, "rocket_job/sliced/writer/output"
15
+ end
16
+
17
+ # Returns [RocketJob::Sliced::Slices] for the relevant type and category.
18
+ #
19
+ # Supports compress and encrypt with [true|false|Hash] values.
20
+ # When [Hash] they must specify whether the apply to the input or output collection types.
21
+ #
22
+ # Example, compress both input and output collections:
23
+ # class MyJob < RocketJob::Job
24
+ # include RocketJob::Batch
25
+ # self.compress = true
26
+ # end
27
+ #
28
+ # Example, compress just the output collections:
29
+ # class MyJob < RocketJob::Job
30
+ # include RocketJob::Batch
31
+ # self.compress = {output: true}
32
+ # end
33
+ #
34
+ # To use the specialized BZip output compressor, and the regular compressor for the input collections:
35
+ # class MyJob < RocketJob::Job
36
+ # include RocketJob::Batch
37
+ # self.compress = {output: :bzip2, input: true}
38
+ # end
39
+ def self.factory(type, category, job)
40
+ raise(ArgumentError, "Unknown type: #{type.inspect}") unless %i[input output].include?(type)
41
+
42
+ collection_name = "rocket_job.#{type}s.#{job.id}"
43
+ collection_name << ".#{category}" unless category == :main
44
+
45
+ args = {collection_name: collection_name, slice_size: job.slice_size}
46
+ klass = slice_class(type, job)
47
+ args[:slice_class] = klass if klass
48
+
49
+ if type == :input
50
+ RocketJob::Sliced::Input.new(args)
51
+ else
52
+ RocketJob::Sliced::Output.new(args)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Parses the encrypt and compress options to determine which slice serializer to use.
59
+ # `encrypt` takes priority over any `compress` option.
60
+ def self.slice_class(type, job)
61
+ encrypt = extract_value(type, job.encrypt)
62
+ compress = extract_value(type, job.compress)
63
+
64
+ if encrypt
65
+ case encrypt
66
+ when true
67
+ EncryptedSlice
68
+ else
69
+ raise(ArgumentError, "Unknown job `encrypt` value: #{compress}") unless compress.is_a?(Slices)
70
+ # Returns the supplied class to use for encryption.
71
+ encrypt
72
+ end
73
+ elsif compress
74
+ case compress
75
+ when true
76
+ CompressedSlice
77
+ when :bzip2
78
+ BZip2OutputSlice
79
+ else
80
+ raise(ArgumentError, "Unknown job `compress` value: #{compress}") unless compress.is_a?(Slices)
81
+ # Returns the supplied class to use for compression.
82
+ compress
83
+ end
84
+ end
85
+ end
86
+
87
+ def self.extract_value(type, value)
88
+ value.is_a?(Hash) ? value[type] : value
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,43 @@
1
+ module RocketJob
2
+ module Sliced
3
+ # This is a specialized output serializer that renders each output slice as a single BZip2 compressed stream.
4
+ # BZip2 allows multiple output streams to be written into a single BZip2 file.
5
+ #
6
+ # Notes:
7
+ # * The `bzip2` linux command line utility supports multiple embedded BZip2 stream,
8
+ # but some other custom implementations may not. They may only read the first slice and stop.
9
+ # * It is only designed for use on output collections.
10
+ #
11
+ # To download the output when using this slice:
12
+ #
13
+ # # Download the binary BZip2 streams into a single file
14
+ # IOStreams.path(output_file_name).stream(:none).writer do |io|
15
+ # job.download { |slice| io << slice[:binary] }
16
+ # end
17
+ class BZip2OutputSlice < ::RocketJob::Sliced::Slice
18
+ # This is a specialized binary slice for creating binary data from each slice
19
+ # that must be downloaded as-is into output files.
20
+ def self.binary?
21
+ true
22
+ end
23
+
24
+ private
25
+
26
+ def parse_records
27
+ records = attributes.delete("records")
28
+
29
+ # Convert BSON::Binary to a string
30
+ @records = [{binary: records.data}]
31
+ end
32
+
33
+ def serialize_records
34
+ return [] if @records.nil? || @records.empty?
35
+
36
+ lines = records.to_a.join("\n")
37
+ s = StringIO.new
38
+ IOStreams::Bzip2::Writer.stream(s) { |io| io.write(lines) }
39
+ BSON::Binary.new(s.string)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -5,7 +5,7 @@ module RocketJob
5
5
  # Create indexes before uploading
6
6
  create_indexes
7
7
  Writer::Input.collect(self, on_first: on_first, &block)
8
- rescue StandardError => e
8
+ rescue Exception => e
9
9
  drop
10
10
  raise(e)
11
11
  end
@@ -73,7 +73,7 @@ module RocketJob
73
73
  count += 1
74
74
  end
75
75
  count
76
- rescue StandardError => e
76
+ rescue Exception => e
77
77
  drop
78
78
  raise(e)
79
79
  end
@@ -91,7 +91,7 @@ module RocketJob
91
91
  count += 1
92
92
  end
93
93
  count
94
- rescue StandardError => e
94
+ rescue Exception => e
95
95
  drop
96
96
  raise(e)
97
97
  end
@@ -94,6 +94,12 @@ module RocketJob
94
94
  end
95
95
  end
96
96
 
97
+ # Returns whether this is a specialized binary slice for creating binary data from each slice
98
+ # that is then just downloaded as-is into output files.
99
+ def self.binary?
100
+ false
101
+ end
102
+
97
103
  # `records` array has special handling so that it can be modified in place instead of having
98
104
  # to replace the entire array every time. For example, when appending lines with `<<`.
99
105
  def records
@@ -42,6 +42,12 @@ module RocketJob
42
42
  slice
43
43
  end
44
44
 
45
+ # Returns whether this collection contains specialized binary slices for creating binary data from each slice
46
+ # that is then just downloaded as-is into output files.
47
+ def binary?
48
+ slice_class.binary?
49
+ end
50
+
45
51
  # Returns output slices in the order of their id
46
52
  # which is usually the order in which they were written.
47
53
  def each
@@ -9,16 +9,22 @@ module RocketJob
9
9
  @supervisor = supervisor
10
10
  end
11
11
 
12
- def kill(server_id: nil, name: nil, wait_timeout: 3)
12
+ def kill(server_id: nil, name: nil, wait_timeout: 5)
13
13
  return unless my_server?(server_id, name)
14
14
 
15
15
  supervisor.synchronize do
16
+ Supervisor.shutdown!
17
+
18
+ supervisor.logger.info("Stopping Pool")
16
19
  supervisor.worker_pool.stop
17
- supervisor.worker_pool.join(wait_timeout)
20
+ unless supervisor.worker_pool.living_count == 0
21
+ supervisor.logger.info("Giving pool #{wait_timeout} seconds to terminate")
22
+ sleep(wait_timeout)
23
+ end
24
+ supervisor.logger.info("Kill Pool")
18
25
  supervisor.worker_pool.kill
19
26
  end
20
27
 
21
- Supervisor.shutdown!
22
28
  logger.info "Killed"
23
29
  end
24
30
 
@@ -55,7 +55,9 @@ module RocketJob
55
55
 
56
56
  def stop!
57
57
  server.stop! if server.may_stop?
58
- worker_pool.stop
58
+ synchronize do
59
+ worker_pool.stop
60
+ end
59
61
  until worker_pool.join
60
62
  logger.info "Waiting for workers to finish processing ..."
61
63
  # One or more workers still running so update heartbeat so that server reports "alive".
@@ -1,3 +1,3 @@
1
1
  module RocketJob
2
- VERSION = "5.2.0".freeze
2
+ VERSION = "5.4.0.beta1".freeze
3
3
  end
@@ -61,6 +61,7 @@ module RocketJob
61
61
  # Kill Worker threads
62
62
  def kill
63
63
  workers.each(&:kill)
64
+ workers.clear
64
65
  end
65
66
 
66
67
  # Wait for all workers to stop.
@@ -13,6 +13,9 @@ require "rocket_job/extensions/mongoid/clients/options"
13
13
  require "rocket_job/extensions/mongoid/contextual/mongo"
14
14
  require "rocket_job/extensions/mongoid/factory"
15
15
 
16
+ # Apply patches for deprecated Symbol type
17
+ require "rocket_job/extensions/mongoid/remove_warnings"
18
+
16
19
  # @formatter:off
17
20
  module RocketJob
18
21
  autoload :ActiveWorker, "rocket_job/active_worker"
@@ -26,6 +29,7 @@ module RocketJob
26
29
  autoload :Worker, "rocket_job/worker"
27
30
  autoload :Performance, "rocket_job/performance"
28
31
  autoload :Server, "rocket_job/server"
32
+ autoload :Sliced, "rocket_job/sliced"
29
33
  autoload :Subscriber, "rocket_job/subscriber"
30
34
  autoload :Supervisor, "rocket_job/supervisor"
31
35
  autoload :ThrottleDefinition, "rocket_job/throttle_definition"
@@ -45,10 +49,6 @@ module RocketJob
45
49
  autoload :Transaction, "rocket_job/plugins/job/transaction"
46
50
  autoload :Worker, "rocket_job/plugins/job/worker"
47
51
  end
48
- module Rufus
49
- autoload :CronLine, "rocket_job/plugins/rufus/cron_line"
50
- autoload :ZoTime, "rocket_job/plugins/rufus/zo_time"
51
- end
52
52
  autoload :Cron, "rocket_job/plugins/cron"
53
53
  autoload :Document, "rocket_job/plugins/document"
54
54
  autoload :ProcessingWindow, "rocket_job/plugins/processing_window"
@@ -71,22 +71,9 @@ module RocketJob
71
71
  autoload :SimpleJob, "rocket_job/jobs/simple_job"
72
72
  autoload :UploadFileJob, "rocket_job/jobs/upload_file_job"
73
73
  module ReEncrypt
74
- autoload :RelationalJob, "rocket_job/jobs/re_encrypt/relational_job"
75
- end
76
- end
77
-
78
- module Sliced
79
- autoload :CompressedSlice, "rocket_job/sliced/compressed_slice"
80
- autoload :EncryptedSlice, "rocket_job/sliced/encrypted_slice"
81
- autoload :Input, "rocket_job/sliced/input"
82
- autoload :Output, "rocket_job/sliced/output"
83
- autoload :Slice, "rocket_job/sliced/slice"
84
- autoload :Slices, "rocket_job/sliced/slices"
85
- autoload :Store, "rocket_job/sliced/store"
86
-
87
- module Writer
88
- autoload :Input, "rocket_job/sliced/writer/input"
89
- autoload :Output, "rocket_job/sliced/writer/output"
74
+ if defined?(ActiveRecord) && defined?(SyncAttr)
75
+ autoload :RelationalJob, "rocket_job/jobs/re_encrypt/relational_job"
76
+ end
90
77
  end
91
78
  end
92
79
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rocketjob
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.0
4
+ version: 5.4.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Reid Morrison
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-30 00:00:00.000000000 Z
11
+ date: 2020-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aasm
@@ -94,8 +94,22 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '4.0'
97
- description:
98
- email:
97
+ - !ruby/object:Gem::Dependency
98
+ name: fugit
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.3'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.3'
111
+ description:
112
+ email:
99
113
  executables:
100
114
  - rocketjob
101
115
  - rocketjob_perf
@@ -124,6 +138,7 @@ files:
124
138
  - lib/rocket_job/batch/tabular/output.rb
125
139
  - lib/rocket_job/batch/throttle.rb
126
140
  - lib/rocket_job/batch/throttle_running_workers.rb
141
+ - lib/rocket_job/batch/throttle_windows.rb
127
142
  - lib/rocket_job/batch/worker.rb
128
143
  - lib/rocket_job/cli.rb
129
144
  - lib/rocket_job/config.rb
@@ -133,6 +148,7 @@ files:
133
148
  - lib/rocket_job/extensions/mongoid/clients/options.rb
134
149
  - lib/rocket_job/extensions/mongoid/contextual/mongo.rb
135
150
  - lib/rocket_job/extensions/mongoid/factory.rb
151
+ - lib/rocket_job/extensions/mongoid/remove_warnings.rb
136
152
  - lib/rocket_job/extensions/rocket_job_adapter.rb
137
153
  - lib/rocket_job/heartbeat.rb
138
154
  - lib/rocket_job/job.rb
@@ -162,8 +178,6 @@ files:
162
178
  - lib/rocket_job/plugins/processing_window.rb
163
179
  - lib/rocket_job/plugins/restart.rb
164
180
  - lib/rocket_job/plugins/retry.rb
165
- - lib/rocket_job/plugins/rufus/cron_line.rb
166
- - lib/rocket_job/plugins/rufus/zo_time.rb
167
181
  - lib/rocket_job/plugins/singleton.rb
168
182
  - lib/rocket_job/plugins/state_machine.rb
169
183
  - lib/rocket_job/plugins/transaction.rb
@@ -172,6 +186,8 @@ files:
172
186
  - lib/rocket_job/server.rb
173
187
  - lib/rocket_job/server/model.rb
174
188
  - lib/rocket_job/server/state_machine.rb
189
+ - lib/rocket_job/sliced.rb
190
+ - lib/rocket_job/sliced/bzip2_output_slice.rb
175
191
  - lib/rocket_job/sliced/compressed_slice.rb
176
192
  - lib/rocket_job/sliced/encrypted_slice.rb
177
193
  - lib/rocket_job/sliced/input.rb
@@ -196,7 +212,7 @@ homepage: http://rocketjob.io
196
212
  licenses:
197
213
  - Apache-2.0
198
214
  metadata: {}
199
- post_install_message:
215
+ post_install_message:
200
216
  rdoc_options: []
201
217
  require_paths:
202
218
  - lib
@@ -207,12 +223,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
207
223
  version: '2.3'
208
224
  required_rubygems_version: !ruby/object:Gem::Requirement
209
225
  requirements:
210
- - - ">="
226
+ - - ">"
211
227
  - !ruby/object:Gem::Version
212
- version: '0'
228
+ version: 1.3.1
213
229
  requirements: []
214
230
  rubygems_version: 3.0.8
215
- signing_key:
231
+ signing_key:
216
232
  specification_version: 4
217
233
  summary: Ruby's missing batch processing system.
218
234
  test_files: []
@@ -1,520 +0,0 @@
1
- #--
2
- # Copyright (c) 2006-2017, 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
- #@formatter:off
25
-
26
- require 'set'
27
-
28
- module RocketJob::Plugins::Rufus
29
-
30
- #
31
- # A 'cron line' is a line in the sense of a crontab
32
- # (man 5 crontab) file line.
33
- #
34
- class CronLine
35
-
36
- # The max number of years in the future or the past before giving up
37
- # searching for #next_time or #previous_time respectively
38
- #
39
- NEXT_TIME_MAX_YEARS = 14
40
-
41
- # The string used for creating this cronline instance.
42
- #
43
- attr_reader :original
44
- attr_reader :original_timezone
45
-
46
- attr_reader :seconds
47
- attr_reader :minutes
48
- attr_reader :hours
49
- attr_reader :days
50
- attr_reader :months
51
- #attr_reader :monthdays # reader defined below
52
- attr_reader :weekdays
53
- attr_reader :timezone
54
-
55
- def initialize(line)
56
-
57
- fail ArgumentError.new(
58
- "not a string: #{line.inspect}"
59
- ) unless line.is_a?(String)
60
-
61
- @original = line
62
- @original_timezone = nil
63
-
64
- items = line.split
65
-
66
- if @timezone = RocketJob::Plugins::Rufus::ZoTime.get_tzone(items.last)
67
- @original_timezone = items.pop
68
- else
69
- @timezone = RocketJob::Plugins::Rufus::ZoTime.get_tzone(:current)
70
- end
71
-
72
- fail ArgumentError.new(
73
- "not a valid cronline : '#{line}'"
74
- ) unless items.length == 5 or items.length == 6
75
-
76
- offset = items.length - 5
77
-
78
- @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ]
79
- @minutes = parse_item(items[0 + offset], 0, 59)
80
- @hours = parse_item(items[1 + offset], 0, 24)
81
- @days = parse_item(items[2 + offset], -30, 31)
82
- @months = parse_item(items[3 + offset], 1, 12)
83
- @weekdays, @monthdays = parse_weekdays(items[4 + offset])
84
-
85
- [ @seconds, @minutes, @hours, @months ].each do |es|
86
-
87
- fail ArgumentError.new(
88
- "invalid cronline: '#{line}'"
89
- ) if es && es.find { |e| ! e.is_a?(Integer) }
90
- end
91
-
92
- if @days && @days.include?(0) # gh-221
93
-
94
- fail ArgumentError.new('invalid day 0 in cronline')
95
- end
96
- end
97
-
98
- # Returns true if the given time matches this cron line.
99
- #
100
- def matches?(time)
101
-
102
- # FIXME Don't create a new ZoTime if time is already a ZoTime in same
103
- # zone ...
104
- # Wait, this seems only used in specs...
105
- t = ZoTime.new(time.to_f, @timezone)
106
-
107
- return false unless sub_match?(t, :sec, @seconds)
108
- return false unless sub_match?(t, :min, @minutes)
109
- return false unless sub_match?(t, :hour, @hours)
110
- return false unless date_match?(t)
111
- true
112
- end
113
-
114
- # Returns the next time that this cron line is supposed to 'fire'
115
- #
116
- # This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
117
- # (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
118
- #
119
- # This method accepts an optional Time parameter. It's the starting point
120
- # for the 'search'. By default, it's Time.now
121
- #
122
- # Note that the time instance returned will be in the same time zone that
123
- # the given start point Time (thus a result in the local time zone will
124
- # be passed if no start time is specified (search start time set to
125
- # Time.now))
126
- #
127
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
128
- # Time.mktime(2008, 10, 24, 7, 29))
129
- # #=> Fri Oct 24 07:30:00 -0500 2008
130
- #
131
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
132
- # Time.utc(2008, 10, 24, 7, 29))
133
- # #=> Fri Oct 24 07:30:00 UTC 2008
134
- #
135
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
136
- # Time.utc(2008, 10, 24, 7, 29)).localtime
137
- # #=> Fri Oct 24 02:30:00 -0500 2008
138
- #
139
- # (Thanks to K Liu for the note and the examples)
140
- #
141
- def next_time(from=ZoTime.now)
142
-
143
- nt = nil
144
- zt = ZoTime.new(from.to_i + 1, @timezone)
145
- maxy = from.year + NEXT_TIME_MAX_YEARS
146
-
147
- loop do
148
-
149
- nt = zt.dup
150
-
151
- fail RangeError.new(
152
- "failed to reach occurrence within " +
153
- "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
154
- ) if nt.year > maxy
155
-
156
- unless date_match?(nt)
157
- zt.add((24 - nt.hour) * 3600 - nt.min * 60 - nt.sec)
158
- next
159
- end
160
- unless sub_match?(nt, :hour, @hours)
161
- zt.add((60 - nt.min) * 60 - nt.sec)
162
- next
163
- end
164
- unless sub_match?(nt, :min, @minutes)
165
- zt.add(60 - nt.sec)
166
- next
167
- end
168
- unless sub_match?(nt, :sec, @seconds)
169
- zt.add(next_second(nt))
170
- next
171
- end
172
-
173
- break
174
- end
175
-
176
- nt
177
- end
178
-
179
- # Returns the previous time the cronline matched. It's like next_time, but
180
- # for the past.
181
- #
182
- def previous_time(from=ZoTime.now)
183
-
184
- pt = nil
185
- zt = ZoTime.new(from.to_i - 1, @timezone)
186
- miny = from.year - NEXT_TIME_MAX_YEARS
187
-
188
- loop do
189
-
190
- pt = zt.dup
191
-
192
- fail RangeError.new(
193
- "failed to reach occurrence within " +
194
- "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
195
- ) if pt.year < miny
196
-
197
- unless date_match?(pt)
198
- zt.substract(pt.hour * 3600 + pt.min * 60 + pt.sec + 1)
199
- next
200
- end
201
- unless sub_match?(pt, :hour, @hours)
202
- zt.substract(pt.min * 60 + pt.sec + 1)
203
- next
204
- end
205
- unless sub_match?(pt, :min, @minutes)
206
- zt.substract(pt.sec + 1)
207
- next
208
- end
209
- unless sub_match?(pt, :sec, @seconds)
210
- zt.substract(prev_second(pt))
211
- next
212
- end
213
-
214
- break
215
- end
216
-
217
- pt
218
- end
219
-
220
- # Returns an array of 6 arrays (seconds, minutes, hours, days,
221
- # months, weekdays).
222
- # This method is mostly used by the cronline specs.
223
- #
224
- def to_a
225
-
226
- [
227
- toa(@seconds),
228
- toa(@minutes),
229
- toa(@hours),
230
- toa(@days),
231
- toa(@months),
232
- toa(@weekdays),
233
- toa(@monthdays),
234
- @timezone.name
235
- ]
236
- end
237
- alias to_array to_a
238
-
239
- # Returns a quickly computed approximation of the frequency for this
240
- # cron line.
241
- #
242
- # #brute_frequency, on the other hand, will compute the frequency by
243
- # examining a whole year, that can take more than seconds for a seconds
244
- # level cron...
245
- #
246
- def frequency
247
-
248
- return brute_frequency unless @seconds && @seconds.length > 1
249
-
250
- secs = toa(@seconds)
251
-
252
- secs[1..-1].inject([ secs[0], 60 ]) { |(prev, delta), sec|
253
- d = sec - prev
254
- [ sec, d < delta ? d : delta ]
255
- }[1]
256
- end
257
-
258
- # Caching facility. Currently only used for brute frequencies.
259
- #
260
- @cache = {}; class << self; attr_reader :cache; end
261
-
262
- # Returns the shortest delta between two potential occurences of the
263
- # schedule described by this cronline.
264
- #
265
- # .
266
- #
267
- # For a simple cronline like "*/5 * * * *", obviously the frequency is
268
- # five minutes. Why does this method look at a whole year of #next_time ?
269
- #
270
- # Consider "* * * * sun#2,sun#3", the computed frequency is 1 week
271
- # (the shortest delta is the one between the second sunday and the third
272
- # sunday). This method takes no chance and runs next_time for the span
273
- # of a whole year and keeps the shortest.
274
- #
275
- # Of course, this method can get VERY slow if you call on it a second-
276
- # based cronline...
277
- #
278
- def brute_frequency
279
-
280
- key = "brute_frequency:#{@original}"
281
-
282
- delta = self.class.cache[key]
283
- return delta if delta
284
-
285
- delta = 366 * DAY_S
286
-
287
- t0 = previous_time(Time.local(2000, 1, 1))
288
-
289
- loop do
290
-
291
- break if delta <= 1
292
- break if delta <= 60 && @seconds && @seconds.size == 1
293
-
294
- t1 = next_time(t0)
295
- d = t1 - t0
296
- delta = d if d < delta
297
- break if @months.nil? && t1.month == 2
298
- break if @months.nil? && @days.nil? && t1.day == 2
299
- break if @months.nil? && @days.nil? && @hours.nil? && t1.hour == 1
300
- break if @months.nil? && @days.nil? && @hours.nil? && @minutes.nil? && t1.min == 1
301
- break if t1.year >= 2001
302
-
303
- t0 = t1
304
- end
305
-
306
- self.class.cache[key] = delta
307
- end
308
-
309
- def next_second(time)
310
-
311
- secs = toa(@seconds)
312
-
313
- return secs.first + 60 - time.sec if time.sec > secs.last
314
-
315
- secs.shift while secs.first < time.sec
316
-
317
- secs.first - time.sec
318
- end
319
-
320
- def prev_second(time)
321
-
322
- secs = toa(@seconds)
323
-
324
- return time.sec + 60 - secs.last if time.sec < secs.first
325
-
326
- secs.pop while time.sec < secs.last
327
-
328
- time.sec - secs.last
329
- end
330
-
331
- protected
332
-
333
- def sc_sort(a)
334
-
335
- a.sort_by { |e| e.is_a?(String) ? 61 : e.to_i }
336
- end
337
-
338
- if RUBY_VERSION >= '1.9'
339
- def toa(item)
340
- item == nil ? nil : item.to_a
341
- end
342
- else
343
- def toa(item)
344
- item.is_a?(Set) ? sc_sort(item.to_a) : item
345
- end
346
- end
347
-
348
- WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
349
- DAY_S = 24 * 3600
350
-
351
- def parse_weekdays(item)
352
-
353
- return nil if item == '*'
354
-
355
- weekdays = nil
356
- monthdays = nil
357
-
358
- item.downcase.split(',').each do |it|
359
-
360
- WEEKDAYS.each_with_index { |a, i| it.gsub!(/#{a}/, i.to_s) }
361
-
362
- it = it.gsub(/([^#])l/, '\1#-1')
363
- # "5L" == "5#-1" == the last Friday
364
-
365
- if m = it.match(/\A(.+)#(l|-?[12345])\z/)
366
-
367
- fail ArgumentError.new(
368
- "ranges are not supported for monthdays (#{it})"
369
- ) if m[1].index('-')
370
-
371
- it = it.gsub(/#l/, '#-1')
372
-
373
- (monthdays ||= []) << it
374
-
375
- else
376
-
377
- fail ArgumentError.new(
378
- "invalid weekday expression (#{item})"
379
- ) if it !~ /\A0*[0-7](-0*[0-7])?\z/
380
-
381
- its = it.index('-') ? parse_range(it, 0, 7) : [ Integer(it) ]
382
- its = its.collect { |i| i == 7 ? 0 : i }
383
-
384
- (weekdays ||= []).concat(its)
385
- end
386
- end
387
-
388
- weekdays = weekdays.uniq.sort if weekdays
389
-
390
- [ weekdays, monthdays ]
391
- end
392
-
393
- def parse_item(item, min, max)
394
-
395
- return nil if item == '*'
396
-
397
- r = item.split(',').map { |i| parse_range(i.strip, min, max) }.flatten
398
-
399
- fail ArgumentError.new(
400
- "found duplicates in #{item.inspect}"
401
- ) if r.uniq.size < r.size
402
-
403
- r = sc_sort(r)
404
-
405
- Set.new(r)
406
- end
407
-
408
- RANGE_REGEX = /\A(\*|-?\d{1,2})(?:-(-?\d{1,2}))?(?:\/(\d{1,2}))?\z/
409
-
410
- def parse_range(item, min, max)
411
-
412
- return %w[ L ] if item == 'L'
413
-
414
- item = '*' + item if item[0, 1] == '/'
415
-
416
- m = item.match(RANGE_REGEX)
417
-
418
- fail ArgumentError.new(
419
- "cannot parse #{item.inspect}"
420
- ) unless m
421
-
422
- mmin = min == -30 ? 1 : min # days
423
-
424
- sta = m[1]
425
- sta = sta == '*' ? mmin : sta.to_i
426
-
427
- edn = m[2]
428
- edn = edn ? edn.to_i : sta
429
- edn = max if m[1] == '*'
430
-
431
- inc = m[3]
432
- inc = inc ? inc.to_i : 1
433
-
434
- fail ArgumentError.new(
435
- "#{item.inspect} positive/negative ranges not allowed"
436
- ) if (sta < 0 && edn > 0) || (sta > 0 && edn < 0)
437
-
438
- fail ArgumentError.new(
439
- "#{item.inspect} descending day ranges not allowed"
440
- ) if min == -30 && sta > edn
441
-
442
- fail ArgumentError.new(
443
- "#{item.inspect} is not in range #{min}..#{max}"
444
- ) if sta < min || edn > max
445
-
446
- fail ArgumentError.new(
447
- "#{item.inspect} increment must be greater than zero"
448
- ) if inc == 0
449
-
450
- r = []
451
- val = sta
452
-
453
- loop do
454
- v = val
455
- v = 0 if max == 24 && v == 24 # hours
456
- r << v
457
- break if inc == 1 && val == edn
458
- val += inc
459
- break if inc > 1 && val > edn
460
- val = min if val > max
461
- end
462
-
463
- r.uniq
464
- end
465
-
466
- # FIXME: Eventually split into day_match?, hour_match? and monthdays_match?o
467
- #
468
- def sub_match?(time, accessor, values)
469
-
470
- return true if values.nil?
471
-
472
- value = time.send(accessor)
473
-
474
- if accessor == :day
475
-
476
- values.each do |v|
477
- return true if v == 'L' && (time + DAY_S).day == 1
478
- return true if v.to_i < 0 && (time + (1 - v) * DAY_S).day == 1
479
- end
480
- end
481
-
482
- if accessor == :hour
483
-
484
- return true if value == 0 && values.include?(24)
485
- end
486
-
487
- if accessor == :monthdays
488
-
489
- return true if (values & value).any?
490
- end
491
-
492
- values.include?(value)
493
- end
494
-
495
- # def monthday_match?(zt, values)
496
- #
497
- # return true if values.nil?
498
- #
499
- # today_values = monthdays(zt)
500
- #
501
- # (today_values & values).any?
502
- # end
503
-
504
- def date_match?(zt)
505
-
506
- return false unless sub_match?(zt, :day, @days)
507
- return false unless sub_match?(zt, :month, @months)
508
-
509
- return true if (
510
- (@weekdays && @monthdays) &&
511
- (sub_match?(zt, :wday, @weekdays) ||
512
- sub_match?(zt, :monthdays, @monthdays)))
513
-
514
- return false unless sub_match?(zt, :wday, @weekdays)
515
- return false unless sub_match?(zt, :monthdays, @monthdays)
516
-
517
- true
518
- end
519
- end
520
- end