procrastinator 1.0.0.pre.rc3 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +285 -294
- data/RELEASE_NOTES.md +10 -5
- data/Rakefile +7 -0
- data/lib/procrastinator/config.rb +34 -3
- data/lib/procrastinator/logged_task.rb +2 -4
- data/lib/procrastinator/queue.rb +26 -2
- data/lib/procrastinator/queue_worker.rb +4 -0
- data/lib/procrastinator/rake/daemon_tasks.rb +16 -9
- data/lib/procrastinator/rspec/matchers.rb +30 -0
- data/lib/procrastinator/scheduler.rb +19 -12
- data/lib/procrastinator/task.rb +12 -0
- data/lib/procrastinator/task_meta_data.rb +12 -0
- data/lib/procrastinator/task_store/file_transaction.rb +53 -55
- data/lib/procrastinator/task_store/simple_comma_store.rb +33 -9
- data/lib/procrastinator/test/mocks.rb +9 -0
- data/lib/procrastinator/version.rb +2 -1
- data/procrastinator.gemspec +1 -0
- metadata +19 -4
data/RELEASE_NOTES.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Release Notes
|
2
2
|
|
3
|
-
## 1.0.0 (
|
3
|
+
## 1.0.0 (2022-09-18)
|
4
4
|
|
5
5
|
### Major Changes
|
6
6
|
|
@@ -21,16 +21,19 @@
|
|
21
21
|
* `Procrastinator::Config#run_process_block`
|
22
22
|
* Removed use of envvar `PROCRASTINATOR_STOP`
|
23
23
|
* `Procrastinator::QueueManager` is merged into `Procrastinator::Scheduler`
|
24
|
-
* Removed rake task to halt queue processes
|
24
|
+
* Removed rake task to halt individual queue processes
|
25
25
|
* Renamed `Procrastinator::Config#provide_context` to `provide_container`
|
26
26
|
* You must now call `Scheduler#work` on the result of `Procrastinator.config`
|
27
|
-
* Use a dedicated process monitor (like `monit`) instead in production environments
|
28
|
-
|
29
|
-
|
27
|
+
* Use a dedicated process monitor (like `monit`) instead in production environments to maintain uptime
|
28
|
+
* `max_tasks` is removed as it only added concurrency complexity. Each queue worker only selects one task from only its
|
29
|
+
queue.
|
30
30
|
* Data is now stored as JSON instead of YAML
|
31
31
|
* Added with_store that applies its settings to its block
|
32
32
|
* `load_with` has been removed
|
33
33
|
* Removed `task_attr` and `Procrastinator::Task` module. Tasks is now duck-type checked for accessors instead.
|
34
|
+
* Added Rake tasks to manage process daemon
|
35
|
+
* Times are passed to Task Store as a Ruby Time object instead of an epoch time integer
|
36
|
+
* `#delay` is now `#defer`
|
34
37
|
|
35
38
|
### Minor Changes
|
36
39
|
|
@@ -38,6 +41,8 @@
|
|
38
41
|
* Updated development gems
|
39
42
|
* Logs now include the queue name in log lines
|
40
43
|
* Logs can now set the shift size or age (like Ruby's Logger)
|
44
|
+
* Log format is now tab-separated to align better and work with POSIX cut
|
45
|
+
* General renaming of terms
|
41
46
|
|
42
47
|
### Bugfixes
|
43
48
|
|
data/Rakefile
CHANGED
@@ -2,7 +2,14 @@
|
|
2
2
|
|
3
3
|
require 'bundler/gem_tasks'
|
4
4
|
require 'rspec/core/rake_task'
|
5
|
+
require 'yard'
|
5
6
|
|
6
7
|
RSpec::Core::RakeTask.new(:spec)
|
7
8
|
|
8
9
|
task default: :spec
|
10
|
+
|
11
|
+
YARD::Rake::YardocTask.new do |t|
|
12
|
+
t.files = %w[lib/**/*.rb]
|
13
|
+
# t.options = %w[--some-option]
|
14
|
+
t.stats_options = ['--list-undoc']
|
15
|
+
end
|
@@ -25,10 +25,20 @@ module Procrastinator
|
|
25
25
|
class Config
|
26
26
|
attr_reader :queues, :log_dir, :log_level, :log_shift_age, :log_shift_size, :container
|
27
27
|
|
28
|
-
|
29
|
-
|
28
|
+
# Default directory to keep logs in.
|
29
|
+
DEFAULT_LOG_DIRECTORY = Pathname.new('log').freeze
|
30
|
+
|
31
|
+
# Default age to keep log files for.
|
32
|
+
# @see Logger
|
33
|
+
DEFAULT_LOG_SHIFT_AGE = 0
|
34
|
+
|
35
|
+
# Default max size to keep log files under.
|
36
|
+
# @see Logger
|
30
37
|
DEFAULT_LOG_SHIFT_SIZE = 2 ** 20 # 1 MB
|
31
|
-
|
38
|
+
|
39
|
+
# Default log formatter
|
40
|
+
# @see Logger
|
41
|
+
DEFAULT_LOG_FORMATTER = proc do |severity, datetime, progname, msg|
|
32
42
|
[datetime.iso8601(8),
|
33
43
|
severity,
|
34
44
|
"#{ progname } (#{ Process.pid }):",
|
@@ -70,10 +80,29 @@ module Procrastinator
|
|
70
80
|
@default_store = old_store
|
71
81
|
end
|
72
82
|
|
83
|
+
# Defines the container to assign to each Task Handler's :container attribute.
|
84
|
+
#
|
85
|
+
# @param container [Object] the container
|
73
86
|
def provide_container(container)
|
74
87
|
@container = container
|
75
88
|
end
|
76
89
|
|
90
|
+
# Defines a queue in the Procrastinator Scheduler.
|
91
|
+
#
|
92
|
+
# The Task Handler will be initialized for each task and assigned each of these attributes:
|
93
|
+
# :container, :logger, :scheduler
|
94
|
+
#
|
95
|
+
# @param name [Symbol, String] the identifier to label the queue. Referenced in #defer calls
|
96
|
+
# @param task_class [Class] the Task Handler class.
|
97
|
+
# @param properties [Hash] Settings options object for this queue
|
98
|
+
# @option properties [Object] :store (Procrastinator::TaskStore::SimpleCommaStore)
|
99
|
+
# Storage strategy for tasks in the queue.
|
100
|
+
# @option properties [Integer] :max_attempts (Procrastinator::Queue::DEFAULT_MAX_ATTEMPTS)
|
101
|
+
# Maximum number of times a task may be attempted before giving up.
|
102
|
+
# @option properties [Integer] :timeout (Procrastinator::Queue::DEFAULT_TIMEOUT)
|
103
|
+
# Maximum number of seconds to wait for a single task to complete.
|
104
|
+
# @option properties [Integer] :update_period (Procrastinator::Queue::DEFAULT_UPDATE_PERIOD)
|
105
|
+
# Time to wait before checking for new tasks.
|
77
106
|
def define_queue(name, task_class, properties = {})
|
78
107
|
raise ArgumentError, 'queue name cannot be nil' if name.nil?
|
79
108
|
raise ArgumentError, 'queue task class cannot be nil' if task_class.nil?
|
@@ -142,7 +171,9 @@ module Procrastinator
|
|
142
171
|
end
|
143
172
|
end
|
144
173
|
|
174
|
+
# Raised when there is an error in the setup configuration.
|
145
175
|
class SetupError < RuntimeError
|
176
|
+
# Standard error message for when there is no queue defined
|
146
177
|
ERR_NO_QUEUE = 'setup block must call #define_queue on the environment'
|
147
178
|
end
|
148
179
|
end
|
@@ -14,10 +14,6 @@ module Procrastinator
|
|
14
14
|
#
|
15
15
|
# @see Task
|
16
16
|
class LoggedTask < DelegateClass(Task)
|
17
|
-
# extend Forwardable
|
18
|
-
#
|
19
|
-
# def_delegators :@task, :id, :to_h
|
20
|
-
|
21
17
|
attr_reader :logger
|
22
18
|
|
23
19
|
alias task __getobj__
|
@@ -27,6 +23,7 @@ module Procrastinator
|
|
27
23
|
@logger = logger || raise(ArgumentError, 'Logger cannot be nil')
|
28
24
|
end
|
29
25
|
|
26
|
+
# (see Task#run)
|
30
27
|
def run
|
31
28
|
task.run
|
32
29
|
|
@@ -37,6 +34,7 @@ module Procrastinator
|
|
37
34
|
end
|
38
35
|
end
|
39
36
|
|
37
|
+
# @param (see Task#fail)
|
40
38
|
def fail(error)
|
41
39
|
hook = task.fail(error)
|
42
40
|
begin
|
data/lib/procrastinator/queue.rb
CHANGED
@@ -18,8 +18,13 @@ module Procrastinator
|
|
18
18
|
class Queue
|
19
19
|
extend Forwardable
|
20
20
|
|
21
|
-
|
22
|
-
|
21
|
+
# Default number of seconds to wait for a task to complete
|
22
|
+
DEFAULT_TIMEOUT = 3600 # in seconds; one hour total
|
23
|
+
|
24
|
+
# Default number of times to retry a task
|
25
|
+
DEFAULT_MAX_ATTEMPTS = 20
|
26
|
+
|
27
|
+
# Default amount of time between checks for new Tasks
|
23
28
|
DEFAULT_UPDATE_PERIOD = 10 # seconds
|
24
29
|
|
25
30
|
attr_reader :name, :max_attempts, :timeout, :update_period, :task_store, :task_class
|
@@ -54,6 +59,12 @@ module Procrastinator
|
|
54
59
|
freeze
|
55
60
|
end
|
56
61
|
|
62
|
+
# Constructs the next available task on the queue.
|
63
|
+
#
|
64
|
+
# @param logger [Logger] logger to provide to the constructed task handler
|
65
|
+
# @param container [Object, nil] container to provide to the constructed task handler
|
66
|
+
# @param scheduler [Procrastinator::Scheduler, nil] the scheduler to provide to the constructed task handler
|
67
|
+
# @return [LoggedTask, nil] A Task or nil if no task is found
|
57
68
|
def next_task(logger: Logger.new(StringIO.new), container: nil, scheduler: nil)
|
58
69
|
metadata = next_metas.find(&:runnable?)
|
59
70
|
|
@@ -67,6 +78,9 @@ module Procrastinator
|
|
67
78
|
LoggedTask.new(task, logger: logger)
|
68
79
|
end
|
69
80
|
|
81
|
+
# Fetch a task matching the given identifier
|
82
|
+
#
|
83
|
+
# @param identifier [Hash] attributes to match
|
70
84
|
def fetch_task(identifier)
|
71
85
|
identifier[:data] = JSON.dump(identifier[:data]) if identifier[:data]
|
72
86
|
|
@@ -78,6 +92,11 @@ module Procrastinator
|
|
78
92
|
TaskMetaData.new(tasks.first.merge(queue: self))
|
79
93
|
end
|
80
94
|
|
95
|
+
# Creates a task on the queue, saved using the Task Store strategy.
|
96
|
+
#
|
97
|
+
# @param run_at [Time] Earliest time to attempt running the task
|
98
|
+
# @param expire_at [Time, nil] Time after which the task will not be attempted
|
99
|
+
# @param data [Hash, String, Numeric, nil] The data to save
|
81
100
|
def create(run_at:, expire_at:, data:)
|
82
101
|
if data.nil? && expects_data?
|
83
102
|
raise ArgumentError, "task #{ @task_class } expects to receive :data. Provide :data to #delay."
|
@@ -102,6 +121,7 @@ module Procrastinator
|
|
102
121
|
@task_store.create(**create_data)
|
103
122
|
end
|
104
123
|
|
124
|
+
# @return [Boolean] whether the task handler will accept data to be assigned via its :data attribute
|
105
125
|
def expects_data?
|
106
126
|
@task_class.method_defined?(:data=)
|
107
127
|
end
|
@@ -136,6 +156,8 @@ module Procrastinator
|
|
136
156
|
|
137
157
|
# Internal queue validator
|
138
158
|
module QueueValidation
|
159
|
+
private
|
160
|
+
|
139
161
|
def validate!
|
140
162
|
verify_task_class!
|
141
163
|
verify_task_store!
|
@@ -198,9 +220,11 @@ module Procrastinator
|
|
198
220
|
include QueueValidation
|
199
221
|
end
|
200
222
|
|
223
|
+
# Raised when a Task Handler does not conform to the expected API
|
201
224
|
class MalformedTaskError < StandardError
|
202
225
|
end
|
203
226
|
|
227
|
+
# Raised when a Task Store strategy does not conform to the expected API
|
204
228
|
class MalformedTaskStoreError < RuntimeError
|
205
229
|
end
|
206
230
|
end
|
@@ -64,6 +64,7 @@ module Procrastinator
|
|
64
64
|
end
|
65
65
|
end
|
66
66
|
|
67
|
+
# Logs halting the queue
|
67
68
|
def halt
|
68
69
|
@logger&.info("Halted worker on queue: #{ name }")
|
69
70
|
@logger&.close
|
@@ -86,6 +87,9 @@ module Procrastinator
|
|
86
87
|
end
|
87
88
|
end
|
88
89
|
|
90
|
+
# Raised when a Task Storage strategy is missing a required part of the API.
|
91
|
+
#
|
92
|
+
# @see TaskStore
|
89
93
|
class MalformedTaskPersisterError < StandardError
|
90
94
|
end
|
91
95
|
end
|
@@ -3,14 +3,20 @@
|
|
3
3
|
require 'rake'
|
4
4
|
|
5
5
|
module Procrastinator
|
6
|
+
# Rake tasks specific to Procrastinator.
|
7
|
+
#
|
8
|
+
# Provide this in your Rakefile:
|
9
|
+
#
|
10
|
+
# require 'procrastinator/rake/task'
|
11
|
+
# Procrastinator::RakeTask.new do
|
12
|
+
# # return your Procrastinator::Scheduler here or construct it using Procrastinator.config
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# And then you will be able to run rake tasks like:
|
16
|
+
#
|
17
|
+
# bundle exec rake procrastinator:start
|
6
18
|
module Rake
|
7
|
-
# RakeTask builder.
|
8
|
-
#
|
9
|
-
# require 'procrastinator/rake/task'
|
10
|
-
# Procrastinator::RakeTask.new('/var/run') do
|
11
|
-
# # return your Procrastinator::Scheduler here or construct it using Procrastinator.config
|
12
|
-
# end
|
13
|
-
#
|
19
|
+
# RakeTask builder class. Use DaemonTasks.define to generate the needed tasks.
|
14
20
|
class DaemonTasks
|
15
21
|
include ::Rake::Cloneable
|
16
22
|
include ::Rake::DSL
|
@@ -26,7 +32,7 @@ module Procrastinator
|
|
26
32
|
# Defines procrastinator:start and procrastinator:stop Rake tasks that operate on the given scheduler.
|
27
33
|
#
|
28
34
|
# @param pid_path [Pathname, File, String, nil] The pid file path
|
29
|
-
# @yieldreturn
|
35
|
+
# @yieldreturn [Procrastinator::Scheduler] Constructed Scheduler to use as basis for starting tasks
|
30
36
|
#
|
31
37
|
# @see Scheduler::DaemonWorking#daemonized!
|
32
38
|
def define(pid_path: nil)
|
@@ -78,8 +84,9 @@ module Procrastinator
|
|
78
84
|
def stop
|
79
85
|
return unless Scheduler::DaemonWorking.running?(@pid_path)
|
80
86
|
|
87
|
+
pid = File.read(@pid_path)
|
81
88
|
Scheduler::DaemonWorking.halt!(@pid_path)
|
82
|
-
warn "Procrastinator pid #{
|
89
|
+
warn "Procrastinator pid #{ pid } halted."
|
83
90
|
end
|
84
91
|
end
|
85
92
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/expectations'
|
4
|
+
|
5
|
+
# Determines if the given task store has a task that matches the expectation hash
|
6
|
+
RSpec::Matchers.define :have_task do |expected_task|
|
7
|
+
match do |task_store|
|
8
|
+
task_store.read.any? do |task|
|
9
|
+
task_hash = task.to_h
|
10
|
+
task_hash[:data] = JSON.parse(task_hash[:data], symbolize_names: true) unless task_hash[:data].empty?
|
11
|
+
|
12
|
+
expected_task.all? do |field, expected_value|
|
13
|
+
expected_value = case field
|
14
|
+
when :queue
|
15
|
+
expected_value.to_sym
|
16
|
+
when :run_at, :initial_run_at, :expire_at, :last_fail_at
|
17
|
+
Time.at(expected_value.to_i)
|
18
|
+
else
|
19
|
+
expected_value
|
20
|
+
end
|
21
|
+
|
22
|
+
values_match? expected_value, task_hash[field]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
description do
|
28
|
+
"have a task with properties #{ expected_task.collect { |k, v| "#{ k }=#{ v }" }.join(', ') }"
|
29
|
+
end
|
30
|
+
end
|
@@ -19,7 +19,7 @@ module Procrastinator
|
|
19
19
|
# @param run_at [Time, Integer] Optional time when this task should be executed. Defaults to the current time.
|
20
20
|
# @param data [Hash, Array, String, Integer] Optional simple data object to be provided to the task on execution.
|
21
21
|
# @param expire_at [Time, Integer] Optional time when the task should be abandoned
|
22
|
-
def
|
22
|
+
def defer(queue_name = nil, data: nil, run_at: Time.now, expire_at: nil)
|
23
23
|
raise ArgumentError, <<~ERR unless queue_name.nil? || queue_name.is_a?(Symbol)
|
24
24
|
must provide a queue name as the first argument. Received: #{ queue_name }
|
25
25
|
ERR
|
@@ -29,13 +29,15 @@ module Procrastinator
|
|
29
29
|
queue.create(run_at: run_at, expire_at: expire_at, data: data)
|
30
30
|
end
|
31
31
|
|
32
|
+
alias delay defer
|
33
|
+
|
32
34
|
# Alters an existing task to run at a new time, expire at a new time, or both.
|
33
35
|
#
|
34
36
|
# Call #to on the result and pass in the new :run_at and/or :expire_at.
|
35
37
|
#
|
36
38
|
# Example:
|
37
39
|
#
|
38
|
-
#
|
40
|
+
# scheduler.reschedule(:alerts, data: {user_id: 5}).to(run_at: Time.now, expire_at: Time.now + 10)
|
39
41
|
#
|
40
42
|
# The identifier can include any data field stored in the task loader. Often this is the information in :data.
|
41
43
|
#
|
@@ -123,6 +125,7 @@ module Procrastinator
|
|
123
125
|
#
|
124
126
|
# @see WorkProxy
|
125
127
|
module ThreadedWorking
|
128
|
+
# Program name. Used as default for pid file names and in logging.
|
126
129
|
PROG_NAME = 'Procrastinator'
|
127
130
|
|
128
131
|
# Work off jobs per queue, each in its own thread.
|
@@ -135,7 +138,7 @@ module Procrastinator
|
|
135
138
|
begin
|
136
139
|
@threads = spawn_threads
|
137
140
|
|
138
|
-
@logger.info "
|
141
|
+
@logger.info "#{ PROG_NAME } running. Process ID: #{ Process.pid }"
|
139
142
|
@threads.each do |thread|
|
140
143
|
thread.join(timeout)
|
141
144
|
end
|
@@ -243,10 +246,13 @@ module Procrastinator
|
|
243
246
|
#
|
244
247
|
# @see WorkProxy
|
245
248
|
module DaemonWorking
|
246
|
-
|
247
|
-
|
249
|
+
# File extension for process ID files
|
250
|
+
PID_EXT = '.pid'
|
251
|
+
|
252
|
+
# Default directory to store PID files in.
|
253
|
+
DEFAULT_PID_DIR = Pathname.new('/tmp').freeze
|
248
254
|
|
249
|
-
# 15 chars is linux limit
|
255
|
+
# Maximum process name size. 15 chars is linux limit
|
250
256
|
MAX_PROC_LEN = 15
|
251
257
|
|
252
258
|
# Consumes the current process and turns it into a background daemon and proceed as #threaded.
|
@@ -290,6 +296,10 @@ module Procrastinator
|
|
290
296
|
false
|
291
297
|
end
|
292
298
|
|
299
|
+
# Raised when a process is already found to exist using the same pid_path
|
300
|
+
class ProcessExistsError < RuntimeError
|
301
|
+
end
|
302
|
+
|
293
303
|
private
|
294
304
|
|
295
305
|
def spawn_daemon(pid_path)
|
@@ -343,10 +353,10 @@ module Procrastinator
|
|
343
353
|
end
|
344
354
|
|
345
355
|
def print_debug_context
|
346
|
-
@logger.debug "Ruby Path: #{ ENV.fetch 'RUBY_ROOT' }"
|
347
|
-
@logger.debug "Bundler Path: #{ ENV.fetch 'BUNDLE_BIN_PATH' }"
|
356
|
+
@logger.debug "Ruby Path: #{ ENV.fetch 'RUBY_ROOT', '?' }"
|
357
|
+
@logger.debug "Bundler Path: #{ ENV.fetch 'BUNDLE_BIN_PATH', '?' }"
|
348
358
|
# LOGNAME is the posix standard and is set by cron, so probably reliable.
|
349
|
-
@logger.debug "Runtime User: #{ ENV.fetch('LOGNAME'
|
359
|
+
@logger.debug "Runtime User: #{ ENV.fetch('LOGNAME', ENV.fetch('USERNAME', '?')) }"
|
350
360
|
end
|
351
361
|
|
352
362
|
def rename_process(pid_path)
|
@@ -384,7 +394,4 @@ module Procrastinator
|
|
384
394
|
end
|
385
395
|
end
|
386
396
|
end
|
387
|
-
|
388
|
-
class ProcessExistsError < RuntimeError
|
389
|
-
end
|
390
397
|
end
|
data/lib/procrastinator/task.rb
CHANGED
@@ -21,6 +21,12 @@ module Procrastinator
|
|
21
21
|
@handler = handler
|
22
22
|
end
|
23
23
|
|
24
|
+
# Executes the Task Handler's #run hook and records the attempt.
|
25
|
+
#
|
26
|
+
# If the #run hook completes successfully, the #success hook will also be executed, if defined.
|
27
|
+
#
|
28
|
+
# @raise [ExpiredError] when the task run_at is after the expired_at.
|
29
|
+
# @raise [AttemptsExhaustedError] when the task has been attempted more times than allowed by the queue settings.
|
24
30
|
def run
|
25
31
|
raise ExpiredError, "task is over its expiry time of #{ @metadata.expire_at.iso8601 }" if @metadata.expired?
|
26
32
|
|
@@ -45,19 +51,25 @@ module Procrastinator
|
|
45
51
|
hook
|
46
52
|
end
|
47
53
|
|
54
|
+
# Attempts to run the given optional event hook on the handler, catching any resultant errors to prevent the whole
|
55
|
+
# task from failing despite the actual work in #run completing.
|
48
56
|
def try_hook(method, *params)
|
49
57
|
@handler.send(method, *params) if @handler.respond_to? method
|
50
58
|
rescue StandardError => e
|
51
59
|
warn "#{ method.to_s.capitalize } hook error: #{ e.message }"
|
52
60
|
end
|
53
61
|
|
62
|
+
# Convert the task into a human-legible string.
|
63
|
+
# @return [String] Including the queue name, id, and serialized data.
|
54
64
|
def to_s
|
55
65
|
"#{ @metadata.queue.name }##{ id } [#{ serialized_data }]"
|
56
66
|
end
|
57
67
|
|
68
|
+
# Raised when a Task's run_at is beyond its expire_at
|
58
69
|
class ExpiredError < RuntimeError
|
59
70
|
end
|
60
71
|
|
72
|
+
# Raised when a Task's attempts has exceeded the max_attempts defined for its queue (if any).
|
61
73
|
class AttemptsExhaustedError < RuntimeError
|
62
74
|
end
|
63
75
|
end
|
@@ -50,6 +50,9 @@ module Procrastinator
|
|
50
50
|
@data = data ? JSON.parse(data, symbolize_names: true) : nil
|
51
51
|
end
|
52
52
|
|
53
|
+
# Increases the number of attempts on this task by one, unless the limit has been reached.
|
54
|
+
#
|
55
|
+
# @raise [Task::AttemptsExhaustedError] when the number of attempts has exceeded the Queue's defined maximum.
|
53
56
|
def add_attempt
|
54
57
|
raise Task::AttemptsExhaustedError unless attempts_left?
|
55
58
|
|
@@ -72,22 +75,28 @@ module Procrastinator
|
|
72
75
|
end
|
73
76
|
end
|
74
77
|
|
78
|
+
# @return [Boolean] whether the task has attempts left and is not expired
|
75
79
|
def retryable?
|
76
80
|
attempts_left? && !expired?
|
77
81
|
end
|
78
82
|
|
83
|
+
# @return [Boolean] whether the task is expired
|
79
84
|
def expired?
|
80
85
|
!@expire_at.nil? && @expire_at < Time.now
|
81
86
|
end
|
82
87
|
|
88
|
+
# @return [Boolean] whether there are attempts left until the Queue's defined maximum is reached (if any)
|
83
89
|
def attempts_left?
|
84
90
|
@queue.max_attempts.nil? || @attempts < @queue.max_attempts
|
85
91
|
end
|
86
92
|
|
93
|
+
# @return [Boolean] whether the task's run_at is exceeded
|
87
94
|
def runnable?
|
88
95
|
!@run_at.nil? && @run_at <= Time.now
|
89
96
|
end
|
90
97
|
|
98
|
+
# @return [Boolean] whether the task's last execution completed successfully.
|
99
|
+
# @raise [RuntimeError] when the task has not been attempted yet or when it is expired
|
91
100
|
def successful?
|
92
101
|
raise 'you cannot check for success before running #work' if !expired? && @attempts <= 0
|
93
102
|
|
@@ -117,6 +126,7 @@ module Procrastinator
|
|
117
126
|
@run_at += 30 + (@attempts ** 4) unless @run_at.nil?
|
118
127
|
end
|
119
128
|
|
129
|
+
# @return [Hash] representation of the task metadata as a hash
|
120
130
|
def to_h
|
121
131
|
{id: @id,
|
122
132
|
queue: @queue.name.to_s,
|
@@ -129,10 +139,12 @@ module Procrastinator
|
|
129
139
|
data: serialized_data}
|
130
140
|
end
|
131
141
|
|
142
|
+
# @return [String] :data serialized as a JSON string
|
132
143
|
def serialized_data
|
133
144
|
JSON.dump(@data)
|
134
145
|
end
|
135
146
|
|
147
|
+
# Resets the last failure time and error.
|
136
148
|
def clear_fails
|
137
149
|
@last_error = nil
|
138
150
|
@last_fail_at = nil
|
@@ -3,74 +3,72 @@
|
|
3
3
|
require 'pathname'
|
4
4
|
|
5
5
|
module Procrastinator
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
@file_mutex = {}
|
6
|
+
# The general idea is that there may be two threads that need to do these actions on the same file:
|
7
|
+
# thread A: read
|
8
|
+
# thread B: read
|
9
|
+
# thread A/B: write
|
10
|
+
# thread A/B: write
|
11
|
+
#
|
12
|
+
# When this sequence happens, the second file write is based on old information and loses the info from
|
13
|
+
# the prior write. Using a global mutex per file path prevents this case.
|
14
|
+
#
|
15
|
+
# This situation can also occur with multi processing, so file locking is also used for solitary access.
|
16
|
+
# File locking is only advisory in some systems, though, so it may only work against other applications
|
17
|
+
# that request a lock.
|
18
|
+
#
|
19
|
+
# @author Robin Miller
|
20
|
+
class FileTransaction
|
21
|
+
# Holds the mutual exclusion locks for file paths by name
|
22
|
+
@file_mutex = {}
|
24
23
|
|
25
|
-
|
26
|
-
|
27
|
-
|
24
|
+
class << self
|
25
|
+
attr_reader :file_mutex
|
26
|
+
end
|
28
27
|
|
29
|
-
|
30
|
-
|
31
|
-
|
28
|
+
def initialize(path)
|
29
|
+
@path = ensure_path(path)
|
30
|
+
end
|
32
31
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
32
|
+
# Alias for transact(writable: false)
|
33
|
+
def read(&block)
|
34
|
+
transact(writable: false, &block)
|
35
|
+
end
|
37
36
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
37
|
+
# Alias for transact(writable: true)
|
38
|
+
def write(&block)
|
39
|
+
transact(writable: true, &block)
|
40
|
+
end
|
42
41
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
42
|
+
# Completes the given block as an atomic transaction locked using a global mutex table.
|
43
|
+
# The block is provided the current file contents.
|
44
|
+
# The block's result is written to the file.
|
45
|
+
def transact(writable: false)
|
46
|
+
semaphore = FileTransaction.file_mutex[@path.to_s] ||= Mutex.new
|
48
47
|
|
49
|
-
|
50
|
-
|
51
|
-
|
48
|
+
semaphore.synchronize do
|
49
|
+
@path.open(writable ? 'r+' : 'r') do |file|
|
50
|
+
file.flock(File::LOCK_EX)
|
52
51
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
end
|
59
|
-
yield_result
|
52
|
+
yield_result = yield(file.read)
|
53
|
+
if writable
|
54
|
+
file.rewind
|
55
|
+
file.write yield_result
|
56
|
+
file.truncate(file.pos)
|
60
57
|
end
|
58
|
+
yield_result
|
61
59
|
end
|
62
60
|
end
|
61
|
+
end
|
63
62
|
|
64
|
-
|
63
|
+
private
|
65
64
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
end
|
72
|
-
path
|
65
|
+
def ensure_path(path)
|
66
|
+
path = Pathname.new path
|
67
|
+
unless path.exist?
|
68
|
+
path.dirname.mkpath
|
69
|
+
FileUtils.touch path
|
73
70
|
end
|
71
|
+
path
|
74
72
|
end
|
75
73
|
end
|
76
74
|
end
|