procrastinator 0.9.0 → 1.0.0.pre.rc2

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.
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module Procrastinator
4
6
  # TaskMetaData objects are State Patterns that record information about the work done on a particular task.
5
7
  #
6
- # It contains the specific information needed to run a task instance. Users define a task class, which describes
7
- # the "how" of a task and TaskMetaData represents the "what" and "when".
8
+ # It contains the specific information needed to run a task instance. Users define a task handler class, which
9
+ # describes the "how" of a task and TaskMetaData represents the "what" and "when".
8
10
  #
9
11
  # It contains task-specific data, timing information, and error records.
10
12
  #
@@ -27,64 +29,63 @@ module Procrastinator
27
29
  # @!attribute [r] :last_fail_at
28
30
  # @return [Integer] Linux epoch timestamp of when the last_error was recorded
29
31
  # @!attribute [r] :data
30
- # @return [String] User-provided data serialized to string using YAML
32
+ # @return [String] App-provided JSON data
31
33
  class TaskMetaData
32
34
  # These are the attributes expected to be in the persistence mechanism
33
35
  EXPECTED_DATA = [:id, :run_at, :initial_run_at, :expire_at, :attempts, :last_error, :last_fail_at, :data].freeze
34
36
 
35
- attr_reader(*EXPECTED_DATA)
37
+ attr_reader(*EXPECTED_DATA, :queue)
36
38
 
37
- def initialize(id: nil,
38
- run_at: nil,
39
- initial_run_at: nil,
40
- expire_at: nil,
41
- attempts: 0,
42
- last_error: nil,
43
- last_fail_at: nil,
44
- data: nil)
39
+ def initialize(id: nil, queue: nil, data: nil,
40
+ run_at: nil, initial_run_at: nil, expire_at: nil,
41
+ attempts: 0, last_error: nil, last_fail_at: nil)
45
42
  @id = id
46
- @run_at = run_at.nil? ? nil : run_at.to_i
47
- @initial_run_at = initial_run_at.to_i
48
- @expire_at = expire_at.nil? ? nil : expire_at.to_i
49
- @attempts = attempts || 0
43
+ @queue = queue || raise(ArgumentError, 'queue cannot be nil')
44
+ @run_at = get_time(run_at)
45
+ @initial_run_at = get_time(initial_run_at) || @run_at
46
+ @expire_at = get_time(expire_at)
47
+ @attempts = (attempts || 0).to_i
50
48
  @last_error = last_error
51
- @last_fail_at = last_fail_at
52
- @data = data ? YAML.safe_load(data, [Symbol, Date]) : nil
53
- end
54
-
55
- def init_task(queue)
56
- @data ? queue.task_class.new(@data) : queue.task_class.new
49
+ @last_fail_at = get_time(last_fail_at)
50
+ @data = data ? JSON.parse(data, symbolize_names: true) : nil
57
51
  end
58
52
 
59
53
  def add_attempt
60
- @attempts += 1
61
- end
54
+ raise Task::AttemptsExhaustedError unless attempts_left?
62
55
 
63
- def clear_fails
64
- @last_error = nil
65
- @last_fail_at = nil
56
+ @attempts += 1
66
57
  end
67
58
 
68
- def fail(msg, final: false)
69
- @last_fail_at = Time.now.to_i
70
- @last_error = msg
71
- @run_at = nil if final
59
+ # Records a failure on this task
60
+ #
61
+ # @param error [StandardError] The error to record
62
+ def failure(error)
63
+ @last_fail_at = Time.now
64
+ @last_error = %[Task failed: #{ error.message }\n#{ error.backtrace&.join("\n") }]
65
+
66
+ if retryable?
67
+ reschedule
68
+ :fail
69
+ else
70
+ @run_at = nil
71
+ :final_fail
72
+ end
72
73
  end
73
74
 
74
- def final_fail?(queue)
75
- too_many_fails?(queue) || expired?
75
+ def retryable?
76
+ attempts_left? && !expired?
76
77
  end
77
78
 
78
79
  def expired?
79
- !@expire_at.nil? && Time.now.to_i > @expire_at
80
+ !@expire_at.nil? && @expire_at < Time.now
80
81
  end
81
82
 
82
- def too_many_fails?(queue)
83
- !queue.max_attempts.nil? && @attempts >= queue.max_attempts
83
+ def attempts_left?
84
+ @queue.max_attempts.nil? || @attempts < @queue.max_attempts
84
85
  end
85
86
 
86
87
  def runnable?
87
- !(@run_at.nil? || Time.now.to_i < @run_at)
88
+ !@run_at.nil? && @run_at <= Time.now
88
89
  end
89
90
 
90
91
  def successful?
@@ -93,26 +94,32 @@ module Procrastinator
93
94
  !expired? && @last_error.nil? && @last_fail_at.nil?
94
95
  end
95
96
 
96
- # TODO: This cop for ** is currently incorrect. This disable can be removed once they fix it.
97
- # rubocop:disable Layout/SpaceAroundOperators
98
- def reschedule
99
- # (30 + n_attempts^4) seconds is chosen to rapidly expand
100
- # but with the baseline of 30s to avoid hitting the disk too frequently.
101
- @run_at += 30 + (@attempts ** 4) unless @run_at.nil?
102
- end
97
+ # Updates the run and/or expiry time. If neither is provided, will reschedule based on the rescheduling
98
+ # calculation algorithm.
99
+ #
100
+ # @param run_at - the new time to run this task
101
+ # @param expire_at - the new time to expire this task
102
+ def reschedule(run_at: nil, expire_at: nil)
103
+ validate_run_at(run_at, expire_at)
103
104
 
104
- # rubocop:enable Layout/SpaceAroundOperators
105
+ @expire_at = expire_at if expire_at
105
106
 
106
- def serialized_data
107
- YAML.dump(@data)
108
- end
107
+ if run_at
108
+ @run_at = @initial_run_at = get_time(run_at)
109
+ clear_fails
110
+ @attempts = 0
111
+ end
109
112
 
110
- def verify_expiry!
111
- raise TaskExpiredError, "task is over its expiry time of #{ @expire_at }" if expired?
113
+ return if run_at || expire_at
114
+
115
+ # (30 + n_attempts^4) seconds is chosen to rapidly expand
116
+ # but with the baseline of 30s to avoid hitting the disk too frequently.
117
+ @run_at += 30 + (@attempts ** 4) unless @run_at.nil?
112
118
  end
113
119
 
114
120
  def to_h
115
121
  {id: @id,
122
+ queue: @queue.name.to_s,
116
123
  run_at: @run_at,
117
124
  initial_run_at: @initial_run_at,
118
125
  expire_at: @expire_at,
@@ -121,8 +128,45 @@ module Procrastinator
121
128
  last_error: @last_error,
122
129
  data: serialized_data}
123
130
  end
124
- end
125
131
 
126
- class TaskExpiredError < StandardError
132
+ def serialized_data
133
+ JSON.dump(@data)
134
+ end
135
+
136
+ def clear_fails
137
+ @last_error = nil
138
+ @last_fail_at = nil
139
+ end
140
+
141
+ private
142
+
143
+ def get_time(data)
144
+ case data
145
+ when NilClass
146
+ nil
147
+ when Numeric
148
+ Time.at data
149
+ when String
150
+ Time.parse data
151
+ when Time
152
+ data
153
+ else
154
+ return data.to_time if data.respond_to? :to_time
155
+
156
+ raise ArgumentError, "Unknown data type: #{ data.class } (#{ data })"
157
+ end
158
+ end
159
+
160
+ def validate_run_at(run_at, expire_at)
161
+ return unless run_at
162
+
163
+ if expire_at && run_at > expire_at
164
+ raise ArgumentError, "new run_at (#{ run_at }) is later than new expire_at (#{ expire_at })"
165
+ end
166
+
167
+ return unless @expire_at && run_at > @expire_at
168
+
169
+ raise ArgumentError, "new run_at (#{ run_at }) is later than existing expire_at (#{ @expire_at })"
170
+ end
127
171
  end
128
172
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Procrastinator
6
+ module TaskStore
7
+ # The general idea is that there may be two threads that need to do these actions on the same file:
8
+ # thread A: read
9
+ # thread B: read
10
+ # thread A/B: write
11
+ # thread A/B: write
12
+ #
13
+ # When this sequence happens, the second file write is based on old information and loses the info from
14
+ # the prior write. Using a global mutex per file path prevents this case.
15
+ #
16
+ # This situation can also occur with multi processing, so file locking is also used for solitary access.
17
+ # File locking is only advisory in some systems, though, so it may only work against other applications
18
+ # that request a lock.
19
+ #
20
+ # @author Robin Miller
21
+ class FileTransaction
22
+ # Holds the mutual exclusion locks for file paths by name
23
+ @file_mutex = {}
24
+
25
+ class << self
26
+ attr_reader :file_mutex
27
+ end
28
+
29
+ def initialize(path)
30
+ @path = ensure_path(path)
31
+ end
32
+
33
+ # Alias for transact(writable: false)
34
+ def read(&block)
35
+ transact(writable: false, &block)
36
+ end
37
+
38
+ # Alias for transact(writable: true)
39
+ def write(&block)
40
+ transact(writable: true, &block)
41
+ end
42
+
43
+ # Completes the given block as an atomic transaction locked using a global mutex table.
44
+ # The block is provided the current file contents.
45
+ # The block's result is written to the file.
46
+ def transact(writable: false)
47
+ semaphore = FileTransaction.file_mutex[@path.to_s] ||= Mutex.new
48
+
49
+ semaphore.synchronize do
50
+ @path.open(writable ? 'r+' : 'r') do |file|
51
+ file.flock(File::LOCK_EX)
52
+
53
+ yield_result = yield(file.read)
54
+ if writable
55
+ file.rewind
56
+ file.write yield_result
57
+ file.truncate(file.pos)
58
+ end
59
+ yield_result
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ensure_path(path)
67
+ path = Pathname.new path
68
+ unless path.exist?
69
+ path.dirname.mkpath
70
+ FileUtils.touch path
71
+ end
72
+ path
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'pathname'
5
+
6
+ module Procrastinator
7
+ module TaskStore
8
+ # Simple Task I/O adapter that writes task information (ie. TaskMetaData attributes) to a CSV file.
9
+ #
10
+ # SimpleCommaStore is not designed for efficiency or large loads (10,000+ tasks).
11
+ #
12
+ # For critical production environments, it is strongly recommended to use a more robust storage mechanism like a
13
+ # proper database.
14
+ #
15
+ # @author Robin Miller
16
+ class SimpleCommaStore
17
+ # ordered
18
+ HEADERS = [:id, :queue, :run_at, :initial_run_at, :expire_at,
19
+ :attempts, :last_fail_at, :last_error, :data].freeze
20
+
21
+ EXT = 'csv'
22
+ DEFAULT_FILE = Pathname.new("procrastinator-tasks.#{ EXT }").freeze
23
+
24
+ TIME_FIELDS = [:run_at, :initial_run_at, :expire_at, :last_fail_at].freeze
25
+
26
+ READ_CONVERTER = proc do |value, field_info|
27
+ if field_info.header == :data
28
+ value
29
+ elsif TIME_FIELDS.include? field_info.header
30
+ value.empty? ? nil : Time.parse(value)
31
+ else
32
+ begin
33
+ Integer(value)
34
+ rescue ArgumentError
35
+ value
36
+ end
37
+ end
38
+ end
39
+
40
+ attr_reader :path
41
+
42
+ def initialize(file_path = DEFAULT_FILE)
43
+ @path = Pathname.new(file_path)
44
+
45
+ if @path.directory? || @path.to_s.end_with?('/')
46
+ @path /= DEFAULT_FILE
47
+ elsif @path.extname.empty?
48
+ @path = @path.dirname / "#{ @path.basename }.csv"
49
+ end
50
+
51
+ @path = @path.expand_path
52
+
53
+ freeze
54
+ end
55
+
56
+ def read(filter = {})
57
+ FileTransaction.new(@path).read do |existing_data|
58
+ parse(existing_data).select do |row|
59
+ filter.keys.all? do |key|
60
+ row[key] == filter[key]
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ # Saves a task to the CSV file.
67
+ #
68
+ # @param queue [String] queue name
69
+ # @param run_at [Time, nil] time to run the task at
70
+ # @param initial_run_at [Time, nil] first time to run the task at. Defaults to run_at.
71
+ # @param expire_at [Time, nil] time to expire the task
72
+ def create(queue:, run_at:, expire_at:, data: '', initial_run_at: nil)
73
+ FileTransaction.new(@path).write do |existing_data|
74
+ tasks = parse(existing_data)
75
+ max_id = tasks.collect { |task| task[:id] }.max || 0
76
+
77
+ new_data = {
78
+ id: max_id + 1,
79
+ queue: queue,
80
+ run_at: run_at,
81
+ initial_run_at: initial_run_at || run_at,
82
+ expire_at: expire_at,
83
+ attempts: 0,
84
+ data: data
85
+ }
86
+
87
+ generate(tasks + [new_data])
88
+ end
89
+ end
90
+
91
+ def update(id, data)
92
+ FileTransaction.new(@path).write do |existing_data|
93
+ tasks = parse(existing_data)
94
+ task_data = tasks.find do |task|
95
+ task[:id] == id
96
+ end
97
+
98
+ task_data&.merge!(data)
99
+
100
+ generate(tasks)
101
+ end
102
+ end
103
+
104
+ def delete(id)
105
+ FileTransaction.new(@path).write do |file_content|
106
+ existing_data = parse(file_content)
107
+ generate(existing_data.reject { |task| task[:id] == id })
108
+ end
109
+ end
110
+
111
+ def generate(data)
112
+ lines = data.collect do |d|
113
+ TIME_FIELDS.each do |field|
114
+ d[field] = d[field]&.iso8601
115
+ end
116
+ CSV.generate_line(d, headers: HEADERS, force_quotes: true).strip
117
+ end
118
+
119
+ lines.unshift(HEADERS.join(','))
120
+
121
+ lines.join("\n") << "\n"
122
+ end
123
+
124
+ private
125
+
126
+ def parse(csv_string)
127
+ data = CSV.parse(csv_string,
128
+ headers: true,
129
+ header_converters: :symbol,
130
+ skip_blanks: true,
131
+ converters: READ_CONVERTER,
132
+ force_quotes: true).to_a
133
+
134
+ headers = data.shift || HEADERS
135
+
136
+ data = data.collect do |d|
137
+ headers.zip(d).to_h
138
+ end
139
+
140
+ correct_types(data)
141
+ end
142
+
143
+ def correct_types(data)
144
+ non_empty_keys = [:run_at, :expire_at, :attempts, :last_fail_at]
145
+
146
+ data.collect do |hash|
147
+ non_empty_keys.each do |key|
148
+ hash.delete(key) if hash[key].is_a?(String) && hash[key].empty?
149
+ end
150
+
151
+ hash[:attempts] ||= 0
152
+
153
+ # hash[:data] = (hash[:data] || '').gsub('""', '"')
154
+ hash[:queue] = hash[:queue].to_sym
155
+
156
+ hash
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Procrastinator
4
+ module Test
5
+ # Testing mock Task class
6
+ #
7
+ # You can use this like:
8
+ #
9
+ # require 'procrastinator/rspec/mocks'
10
+ # # ...
11
+ # Procrastinator.config do |c|
12
+ # c.define_queue :test_queue, Procrastinator::RSpec::MockTask
13
+ # end
14
+ #
15
+ # @see MockDataTask for data-accepting tasks
16
+ class MockTask
17
+ attr_accessor :container, :logger, :scheduler
18
+
19
+ def run
20
+ @run = true
21
+ end
22
+
23
+ def run?
24
+ @run
25
+ end
26
+ end
27
+
28
+ # Data-accepting MockTask
29
+ #
30
+ # @see MockTask
31
+ class MockDataTask < MockTask
32
+ attr_accessor :data
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Procrastinator
4
- VERSION = '0.9.0'
4
+ VERSION = '1.0.0-rc2'
5
5
  end
@@ -2,52 +2,37 @@
2
2
 
3
3
  require 'procrastinator/version'
4
4
  require 'procrastinator/task_meta_data'
5
- require 'procrastinator/task_worker'
5
+ require 'procrastinator/logged_task'
6
6
  require 'procrastinator/queue'
7
7
  require 'procrastinator/queue_worker'
8
8
  require 'procrastinator/config'
9
- require 'procrastinator/queue_manager'
10
9
  require 'procrastinator/task'
11
10
  require 'procrastinator/scheduler'
12
- require 'procrastinator/loaders/csv_loader'
11
+ require 'procrastinator/task_store/file_transaction'
12
+ require 'procrastinator/task_store/simple_comma_store'
13
13
 
14
14
  require 'logger'
15
15
  require 'pathname'
16
16
 
17
17
  # Top-level module for the Procrastinator Gem.
18
18
  #
19
- # Call Procrastinator.setup with a block to initialize and run independent worker sub processes to complete tasks
20
- # asynchronously from your main application.
19
+ # Call Procrastinator.setup with a block to configure task queues.
21
20
  #
22
- # Read the README for details.
21
+ # See README for details.
23
22
  #
24
23
  # @author Robin Miller
25
24
  #
26
25
  # @see https://github.com/TenjinInc/procrastinator
27
26
  module Procrastinator
28
- # rubocop:disable Style/ClassVars
29
- @@test_mode = false
30
-
31
- def self.test_mode=(value)
32
- @@test_mode = value
33
- end
34
-
35
- def self.test_mode
36
- @@test_mode
37
- end
38
-
39
- # rubocop:enable Style/ClassVars
40
-
41
27
  # Creates a configuration object and passes it into the given block.
42
28
  #
43
29
  # @yield the created configuration object
30
+ # @return [Scheduler] a scheduler object that can be used to interact with the queues
44
31
  def self.setup(&block)
45
- raise ArgumentError, 'Procrastinator.setup must be given a block' unless block_given?
46
-
47
- config = Config.new
32
+ raise ArgumentError, 'Procrastinator.setup must be given a block' unless block
48
33
 
49
- config.setup(@@test_mode, &block)
34
+ config = Config.new(&block)
50
35
 
51
- QueueManager.new(config).spawn_workers
36
+ Scheduler.new(config)
52
37
  end
53
38
  end
@@ -10,23 +10,27 @@ Gem::Specification.new do |spec|
10
10
  spec.authors = ['Robin Miller']
11
11
  spec.email = ['robin@tenjin.ca']
12
12
 
13
- spec.summary = 'For apps that put work off until later'
14
- spec.description = 'A straightforward, customizable Ruby job queue with zero dependencies.'
13
+ spec.summary = 'For apps to put off work until later'
14
+ spec.description = 'A flexible pure Ruby job queue. Tasks are reschedulable after failures.'
15
15
  spec.homepage = 'https://github.com/TenjinInc/procrastinator'
16
16
  spec.license = 'MIT'
17
+ spec.metadata = {
18
+ 'rubygems_mfa_required' => 'true'
19
+ }
17
20
 
18
21
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
22
  spec.bindir = 'exe'
20
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
24
  spec.require_paths = ['lib']
22
25
 
23
- spec.required_ruby_version = '>= 2.3'
26
+ spec.required_ruby_version = '>= 2.4'
24
27
 
25
- spec.add_development_dependency 'bundler', '~> 1.11'
26
- spec.add_development_dependency 'fakefs', '~> 0.10'
27
- spec.add_development_dependency 'rake', '~> 12.3'
28
- spec.add_development_dependency 'rspec', '~> 3.0'
29
- spec.add_development_dependency 'rubocop', '~> 0.58'
30
- spec.add_development_dependency 'simplecov', '~> 0.16.1'
28
+ spec.add_development_dependency 'bundler', '~> 2.3'
29
+ spec.add_development_dependency 'fakefs', '~> 1.8'
30
+ spec.add_development_dependency 'rake', '~> 13.0'
31
+ spec.add_development_dependency 'rspec', '~> 3.9'
32
+ spec.add_development_dependency 'rubocop', '~> 1.12'
33
+ spec.add_development_dependency 'rubocop-performance', '~> 1.10'
34
+ spec.add_development_dependency 'simplecov', '~> 0.18.0'
31
35
  spec.add_development_dependency 'timecop', '~> 0.9'
32
36
  end