procrastinator 0.9.0 → 1.0.0.pre.rc2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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