procrastinator 0.6.1 → 1.0.0.pre.rc2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +6 -1
- data/.rubocop.yml +26 -0
- data/.ruby-version +1 -1
- data/Gemfile +2 -0
- data/README.md +492 -144
- data/RELEASE_NOTES.md +44 -0
- data/Rakefile +5 -3
- data/lib/procrastinator/config.rb +149 -0
- data/lib/procrastinator/logged_task.rb +50 -0
- data/lib/procrastinator/queue.rb +206 -0
- data/lib/procrastinator/queue_worker.rb +66 -91
- data/lib/procrastinator/rake/daemon_tasks.rb +54 -0
- data/lib/procrastinator/rake/tasks.rb +3 -0
- data/lib/procrastinator/scheduler.rb +393 -0
- data/lib/procrastinator/task.rb +64 -0
- data/lib/procrastinator/task_meta_data.rb +172 -0
- data/lib/procrastinator/task_store/file_transaction.rb +76 -0
- data/lib/procrastinator/task_store/simple_comma_store.rb +161 -0
- data/lib/procrastinator/test/mocks.rb +35 -0
- data/lib/procrastinator/version.rb +3 -1
- data/lib/procrastinator.rb +29 -23
- data/procrastinator.gemspec +17 -11
- metadata +66 -28
- data/lib/procrastinator/environment.rb +0 -148
- data/lib/procrastinator/task_worker.rb +0 -120
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rake'
|
4
|
+
|
5
|
+
module Procrastinator
|
6
|
+
module Rake
|
7
|
+
# RakeTask builder. Provide this in your Rakefile:
|
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
|
+
#
|
14
|
+
class DaemonTasks
|
15
|
+
include ::Rake::Cloneable
|
16
|
+
include ::Rake::DSL
|
17
|
+
|
18
|
+
# Shorthand for DaemonTasks.new.define
|
19
|
+
#
|
20
|
+
# @param (see #define)
|
21
|
+
# @see DaemonTasks#define
|
22
|
+
def self.define(**args)
|
23
|
+
new.define(**args)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Defines procrastinator:start and procrastinator:stop Rake tasks that operate on the given scheduler.
|
27
|
+
# If provided a block, that block will run in the daemon process.
|
28
|
+
#
|
29
|
+
# @param scheduler [Procrastinator::Scheduler]
|
30
|
+
# @param pid_path [Pathname, File, String, nil]
|
31
|
+
def define(scheduler:, pid_path: nil, &block)
|
32
|
+
pid_path = Scheduler::DaemonWorking.normalize_pid(pid_path)
|
33
|
+
|
34
|
+
namespace :procrastinator do
|
35
|
+
task :start do
|
36
|
+
scheduler.work.daemonized!(pid_path, &block)
|
37
|
+
end
|
38
|
+
|
39
|
+
task :status do
|
40
|
+
if Scheduler::DaemonWorking.running?(pid_path)
|
41
|
+
warn "Procrastinator instance running (pid #{ File.read(pid_path) })"
|
42
|
+
else
|
43
|
+
warn "No Procrastinator instance detected for #{ pid_path }"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
task :stop do
|
48
|
+
Scheduler::DaemonWorking.halt!(pid_path)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,393 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stringio'
|
4
|
+
|
5
|
+
module Procrastinator
|
6
|
+
# A Scheduler object provides the API for client applications to manage delayed tasks.
|
7
|
+
#
|
8
|
+
# Use #delay to schedule new tasks, #reschedule to alter existing tasks, and #cancel to remove unwanted tasks.
|
9
|
+
#
|
10
|
+
# @author Robin Miller
|
11
|
+
class Scheduler
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
# Records a new task to be executed at the given time.
|
17
|
+
#
|
18
|
+
# @param queue_name [Symbol] the symbol identifier for the queue to add a new task on
|
19
|
+
# @param run_at [Time, Integer] Optional time when this task should be executed. Defaults to the current time.
|
20
|
+
# @param data [Hash, Array, String, Integer] Optional simple data object to be provided to the task on execution.
|
21
|
+
# @param expire_at [Time, Integer] Optional time when the task should be abandoned
|
22
|
+
def delay(queue_name = nil, data: nil, run_at: Time.now, expire_at: nil)
|
23
|
+
raise ArgumentError, <<~ERR unless queue_name.nil? || queue_name.is_a?(Symbol)
|
24
|
+
must provide a queue name as the first argument. Received: #{ queue_name }
|
25
|
+
ERR
|
26
|
+
|
27
|
+
queue = @config.queue(name: queue_name)
|
28
|
+
|
29
|
+
queue.create(run_at: run_at, expire_at: expire_at, data: data)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Alters an existing task to run at a new time, expire at a new time, or both.
|
33
|
+
#
|
34
|
+
# Call #to on the result and pass in the new :run_at and/or :expire_at.
|
35
|
+
#
|
36
|
+
# Example:
|
37
|
+
#
|
38
|
+
# scheduler.reschedule(:alerts, data: {user_id: 5}).to(run_at: Time.now, expire_at: Time.now + 10)
|
39
|
+
#
|
40
|
+
# The identifier can include any data field stored in the task loader. Often this is the information in :data.
|
41
|
+
#
|
42
|
+
# @param queue [Symbol] the symbol identifier for the queue to add a new task on
|
43
|
+
# @param identifier [Hash] Some identifying information to find the appropriate task.
|
44
|
+
#
|
45
|
+
# @see TaskMetaData
|
46
|
+
def reschedule(queue, identifier)
|
47
|
+
UpdateProxy.new(@config, identifier: identifier.merge(queue: queue.to_s))
|
48
|
+
end
|
49
|
+
|
50
|
+
# Removes an existing task, as located by the givne identifying information.
|
51
|
+
#
|
52
|
+
# The identifier can include any data field stored in the task loader. Often this is the information in :data.
|
53
|
+
#
|
54
|
+
# @param queue [Symbol] the symbol identifier for the queue to add a new task on
|
55
|
+
# @param identifier [Hash] Some identifying information to find the appropriate task.
|
56
|
+
#
|
57
|
+
# @see TaskMetaData
|
58
|
+
def cancel(queue, identifier)
|
59
|
+
queue = @config.queue(name: queue)
|
60
|
+
|
61
|
+
tasks = queue.read(identifier.merge(queue: queue.name.to_s))
|
62
|
+
|
63
|
+
raise "no task matches search: #{ identifier }" if tasks.empty?
|
64
|
+
raise "multiple tasks match search: #{ identifier }" if tasks.size > 1
|
65
|
+
|
66
|
+
queue.delete(tasks.first[:id])
|
67
|
+
end
|
68
|
+
|
69
|
+
# Spawns a new worker thread for each queue defined in the config
|
70
|
+
#
|
71
|
+
# @param queue_names [Array<String,Symbol>] Names of specific queues to act upon.
|
72
|
+
# Omit or leave empty to act on all queues.
|
73
|
+
def work(*queue_names)
|
74
|
+
queue_names = @config.queues if queue_names.empty?
|
75
|
+
|
76
|
+
workers = queue_names.collect do |queue_name|
|
77
|
+
QueueWorker.new(queue: queue_name, config: @config)
|
78
|
+
end
|
79
|
+
|
80
|
+
WorkProxy.new(workers, @config)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Provides a more natural syntax for rescheduling tasks
|
84
|
+
#
|
85
|
+
# @see Scheduler#reschedule
|
86
|
+
class UpdateProxy
|
87
|
+
def initialize(queue, identifier:)
|
88
|
+
@queue = queue
|
89
|
+
@identifier = identifier
|
90
|
+
end
|
91
|
+
|
92
|
+
def to(run_at: nil, expire_at: nil)
|
93
|
+
task = @queue.fetch_task(@identifier)
|
94
|
+
|
95
|
+
raise ArgumentError, 'you must provide at least :run_at or :expire_at' if run_at.nil? && expire_at.nil?
|
96
|
+
|
97
|
+
task.reschedule(expire_at: expire_at) if expire_at
|
98
|
+
task.reschedule(run_at: run_at) if run_at
|
99
|
+
|
100
|
+
new_data = task.to_h
|
101
|
+
new_data.delete(:queue)
|
102
|
+
new_data.delete(:data)
|
103
|
+
@queue.update(new_data.delete(:id), new_data)
|
104
|
+
end
|
105
|
+
|
106
|
+
alias at to
|
107
|
+
end
|
108
|
+
|
109
|
+
# Serial work style
|
110
|
+
#
|
111
|
+
# @see WorkProxy
|
112
|
+
module SerialWorking
|
113
|
+
# Work off the given number of tasks for each queue and return
|
114
|
+
# @param steps [integer] The number of tasks to complete.
|
115
|
+
def serially(steps: 1)
|
116
|
+
steps.times do
|
117
|
+
workers.each(&:work_one)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Threaded work style
|
123
|
+
#
|
124
|
+
# @see WorkProxy
|
125
|
+
module ThreadedWorking
|
126
|
+
PROG_NAME = 'Procrastinator'
|
127
|
+
|
128
|
+
# Work off jobs per queue, each in its own thread.
|
129
|
+
#
|
130
|
+
# @param timeout Maximum number of seconds to run for. If nil, will run indefinitely.
|
131
|
+
def threaded(timeout: nil)
|
132
|
+
open_log
|
133
|
+
shutdown_on_interrupt
|
134
|
+
|
135
|
+
begin
|
136
|
+
@threads = spawn_threads
|
137
|
+
|
138
|
+
@logger.info "Procrastinator running. Process ID: #{ Process.pid }"
|
139
|
+
@threads.each do |thread|
|
140
|
+
thread.join(timeout)
|
141
|
+
end
|
142
|
+
rescue StandardError => e
|
143
|
+
thread_crash(e)
|
144
|
+
ensure
|
145
|
+
@logger&.info 'Halting worker threads...'
|
146
|
+
shutdown!
|
147
|
+
@logger&.info 'Threads halted.'
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
def spawn_threads
|
154
|
+
@logger.info "Starting workers for queues: #{ @workers.collect(&:name).join(', ') }"
|
155
|
+
|
156
|
+
@workers.collect do |worker|
|
157
|
+
@logger.debug "Spawning thread: #{ worker.name }"
|
158
|
+
Thread.new(worker) do |w|
|
159
|
+
Thread.current.abort_on_exception = true
|
160
|
+
Thread.current.thread_variable_set(:name, w.name)
|
161
|
+
|
162
|
+
begin
|
163
|
+
worker.work!
|
164
|
+
ensure
|
165
|
+
worker.halt
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def thread_crash(error)
|
172
|
+
crashed_threads = (@threads || []).select { |t| t.status.nil? }.collect do |thread|
|
173
|
+
"Crashed thread: #{ thread.thread_variable_get(:name) }"
|
174
|
+
end
|
175
|
+
|
176
|
+
@logger.fatal <<~MSG
|
177
|
+
Crash detected in queue worker thread.
|
178
|
+
#{ crashed_threads.join("\n") }
|
179
|
+
#{ error.message }
|
180
|
+
#{ error.backtrace.join("\n\t") }"
|
181
|
+
MSG
|
182
|
+
end
|
183
|
+
|
184
|
+
def shutdown_on_interrupt
|
185
|
+
Signal.trap('INT') do
|
186
|
+
warn "\n" # just to separate the shutdown log item
|
187
|
+
shutdown!
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def shutdown!
|
192
|
+
(@threads || []).select(&:alive?).each(&:kill)
|
193
|
+
end
|
194
|
+
|
195
|
+
def open_log(quiet: false)
|
196
|
+
return if @logger
|
197
|
+
|
198
|
+
log_devs = []
|
199
|
+
|
200
|
+
log_devs << StringIO.new if quiet && !@config.log_level
|
201
|
+
log_devs << $stderr unless quiet
|
202
|
+
log_devs << log_path.open('a') if @config.log_level
|
203
|
+
|
204
|
+
multi = MultiIO.new(*log_devs)
|
205
|
+
multi.sync = true
|
206
|
+
|
207
|
+
@logger = Logger.new(multi,
|
208
|
+
progname: PROG_NAME.downcase,
|
209
|
+
level: @config.log_level || Logger::INFO,
|
210
|
+
formatter: Config::DEFAULT_LOG_FORMATTER)
|
211
|
+
end
|
212
|
+
|
213
|
+
def log_path
|
214
|
+
path = @config.log_dir / "#{ PROG_NAME.downcase }.log"
|
215
|
+
path.dirname.mkpath
|
216
|
+
# FileUtils.touch(log_path)
|
217
|
+
path
|
218
|
+
end
|
219
|
+
|
220
|
+
# IO Multiplexer that forwards calls to a list of IO streams.
|
221
|
+
class MultiIO
|
222
|
+
def initialize(*stream)
|
223
|
+
@streams = stream
|
224
|
+
end
|
225
|
+
|
226
|
+
(IO.methods << :path << :sync=).uniq.each do |method_name|
|
227
|
+
define_method(method_name) do |*args|
|
228
|
+
able_streams(method_name).collect do |stream|
|
229
|
+
stream.send(method_name, *args)
|
230
|
+
end.last # forces consistent return result type for callers (but may lose some info)
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
private
|
235
|
+
|
236
|
+
def able_streams(method_name)
|
237
|
+
@streams.select { |stream| stream.respond_to?(method_name) }
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Daemonized work style
|
243
|
+
#
|
244
|
+
# @see WorkProxy
|
245
|
+
module DaemonWorking
|
246
|
+
PID_EXT = '.pid'
|
247
|
+
DEFAULT_PID_DIR = Pathname.new('/var/run/').freeze
|
248
|
+
|
249
|
+
# 15 chars is linux limit
|
250
|
+
MAX_PROC_LEN = 15
|
251
|
+
|
252
|
+
# Consumes the current process and turns it into a background daemon. A log will be started in the log
|
253
|
+
# directory defined in the configuration block.
|
254
|
+
#
|
255
|
+
# If pid_path ends with extension '.pid', the basename will be requested as process title (depending on OS
|
256
|
+
# support). An extensionless path is assumed to be a directory and a default filename (and proctitle) is used.
|
257
|
+
#
|
258
|
+
# @param pid_path [Pathname, File, String, nil] Path to where the process ID file is to be kept.
|
259
|
+
# @yield [void] Block to run after daemonization
|
260
|
+
def daemonized!(pid_path = nil, &block)
|
261
|
+
spawn_daemon(pid_path, &block)
|
262
|
+
|
263
|
+
threaded
|
264
|
+
end
|
265
|
+
|
266
|
+
# Normalizes the given pid path, including conversion to absolute path and defaults.
|
267
|
+
#
|
268
|
+
# @param pid_path [Pathname, String] path to normalize
|
269
|
+
def self.normalize_pid(pid_path)
|
270
|
+
pid_path = Pathname.new(pid_path || DEFAULT_PID_DIR)
|
271
|
+
pid_path /= "#{ PROG_NAME.downcase }#{ PID_EXT }" unless pid_path.extname == PID_EXT
|
272
|
+
|
273
|
+
pid_path.expand_path
|
274
|
+
end
|
275
|
+
|
276
|
+
# Stops the procrastinator process denoted by the provided pid file
|
277
|
+
def self.halt!(pid_path)
|
278
|
+
pid_path = normalize_pid pid_path
|
279
|
+
|
280
|
+
Process.kill('TERM', pid_path.read.to_i)
|
281
|
+
end
|
282
|
+
|
283
|
+
def self.running?(pid_path)
|
284
|
+
pid = normalize_pid(pid_path).read.to_i
|
285
|
+
|
286
|
+
# this raises Errno::ESRCH when no process found, therefore if found we should exit
|
287
|
+
Process.getpgid pid
|
288
|
+
|
289
|
+
true
|
290
|
+
rescue Errno::ESRCH
|
291
|
+
false
|
292
|
+
end
|
293
|
+
|
294
|
+
private
|
295
|
+
|
296
|
+
# "You, search from the spastic dentistry department down through disembowelment. You, cover children's dance
|
297
|
+
# recitals through holiday weekend IKEA. Go."
|
298
|
+
def spawn_daemon(pid_path, &block)
|
299
|
+
pid_path = DaemonWorking.normalize_pid pid_path
|
300
|
+
|
301
|
+
open_log quiet: true
|
302
|
+
@logger.info "Starting #{ PROG_NAME } daemon..."
|
303
|
+
|
304
|
+
print_debug_context
|
305
|
+
|
306
|
+
Process.daemon
|
307
|
+
|
308
|
+
manage_pid pid_path
|
309
|
+
rename_process pid_path
|
310
|
+
|
311
|
+
yield if block
|
312
|
+
rescue StandardError => e
|
313
|
+
@logger.fatal ([e.message] + e.backtrace).join("\n")
|
314
|
+
raise e
|
315
|
+
end
|
316
|
+
|
317
|
+
def manage_pid(pid_path)
|
318
|
+
ensure_unique(pid_path)
|
319
|
+
|
320
|
+
@logger.debug "Managing pid at path: #{ pid_path }"
|
321
|
+
pid_path.dirname.mkpath
|
322
|
+
pid_path.write Process.pid.to_s
|
323
|
+
|
324
|
+
at_exit do
|
325
|
+
if pid_path.exist?
|
326
|
+
@logger.debug "Cleaning up pid file #{ pid_path }"
|
327
|
+
pid_path.delete
|
328
|
+
end
|
329
|
+
@logger.info "Procrastinator (pid #{ Process.pid }) halted."
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
def ensure_unique(pid_path)
|
334
|
+
return unless pid_path.exist?
|
335
|
+
|
336
|
+
@logger.debug "Checking pid file #{ pid_path }"
|
337
|
+
|
338
|
+
if DaemonWorking.running? pid_path
|
339
|
+
hint = 'Either terminate that process or remove the pid file (if coincidental).'
|
340
|
+
msg = "Another process (pid #{ pid_path.read }) already exists for #{ pid_path }. #{ hint }"
|
341
|
+
@logger.fatal msg
|
342
|
+
raise ProcessExistsError, msg
|
343
|
+
else
|
344
|
+
@logger.warn "Replacing old pid file of defunct process (pid #{ pid_path.read }) at #{ pid_path }."
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
def print_debug_context
|
349
|
+
@logger.debug "Ruby Path: #{ ENV['RUBY_ROOT'] }"
|
350
|
+
@logger.debug "Bundler Path: #{ ENV['BUNDLE_BIN_PATH'] }"
|
351
|
+
# logname is the posix standard and is set by cron, so probably reliable.
|
352
|
+
@logger.debug "Runtime User: #{ ENV['LOGNAME'] || ENV['USERNAME'] }"
|
353
|
+
end
|
354
|
+
|
355
|
+
def rename_process(pid_path)
|
356
|
+
name = pid_path.basename(PID_EXT).to_s
|
357
|
+
|
358
|
+
if name.size > MAX_PROC_LEN
|
359
|
+
@logger.warn "Process name is longer than max length (#{ MAX_PROC_LEN }). Trimming to fit."
|
360
|
+
name = name[0, MAX_PROC_LEN]
|
361
|
+
end
|
362
|
+
|
363
|
+
if system('pidof', name, out: File::NULL)
|
364
|
+
@logger.warn "Another process is already named '#{ name }'. Consider the 'name:' keyword to distinguish."
|
365
|
+
end
|
366
|
+
|
367
|
+
@logger.debug "Renaming process to: #{ name }"
|
368
|
+
Process.setproctitle name
|
369
|
+
end
|
370
|
+
|
371
|
+
include ThreadedWorking
|
372
|
+
end
|
373
|
+
|
374
|
+
# DSL grammar object to enable chaining #work with the three work modes.
|
375
|
+
#
|
376
|
+
# @see Scheduler#work
|
377
|
+
class WorkProxy
|
378
|
+
include SerialWorking
|
379
|
+
include ThreadedWorking
|
380
|
+
include DaemonWorking
|
381
|
+
|
382
|
+
attr_reader :workers
|
383
|
+
|
384
|
+
def initialize(workers, config)
|
385
|
+
@workers = workers
|
386
|
+
@config = config
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
class ProcessExistsError < RuntimeError
|
392
|
+
end
|
393
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'time'
|
5
|
+
|
6
|
+
module Procrastinator
|
7
|
+
# Wraps a task handler and task metadata
|
8
|
+
#
|
9
|
+
# @author Robin Miller
|
10
|
+
class Task
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
def_delegators :@metadata,
|
14
|
+
:id, :run_at, :initial_run_at, :expire_at,
|
15
|
+
:attempts, :last_fail_at, :last_error,
|
16
|
+
:data, :to_h, :serialized_data,
|
17
|
+
:queue, :reschedule
|
18
|
+
|
19
|
+
def initialize(metadata, handler)
|
20
|
+
@metadata = metadata
|
21
|
+
@handler = handler
|
22
|
+
end
|
23
|
+
|
24
|
+
def run
|
25
|
+
raise ExpiredError, "task is over its expiry time of #{ @metadata.expire_at.iso8601 }" if @metadata.expired?
|
26
|
+
|
27
|
+
@metadata.add_attempt
|
28
|
+
result = Timeout.timeout(queue.timeout) do
|
29
|
+
@handler.run
|
30
|
+
end
|
31
|
+
@metadata.clear_fails
|
32
|
+
|
33
|
+
try_hook(:success, result)
|
34
|
+
end
|
35
|
+
|
36
|
+
alias call run
|
37
|
+
|
38
|
+
# Records a failure in metadata and attempts to run the handler's #fail hook if present.
|
39
|
+
#
|
40
|
+
# @param error [StandardError] - the error that caused the failure
|
41
|
+
def fail(error)
|
42
|
+
hook = @metadata.failure(error)
|
43
|
+
|
44
|
+
try_hook(hook, error)
|
45
|
+
hook
|
46
|
+
end
|
47
|
+
|
48
|
+
def try_hook(method, *params)
|
49
|
+
@handler.send(method, *params) if @handler.respond_to? method
|
50
|
+
rescue StandardError => e
|
51
|
+
warn "#{ method.to_s.capitalize } hook error: #{ e.message }"
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_s
|
55
|
+
"#{ @metadata.queue.name }##{ id } [#{ serialized_data }]"
|
56
|
+
end
|
57
|
+
|
58
|
+
class ExpiredError < RuntimeError
|
59
|
+
end
|
60
|
+
|
61
|
+
class AttemptsExhaustedError < RuntimeError
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module Procrastinator
|
6
|
+
# TaskMetaData objects are State Patterns that record information about the work done on a particular task.
|
7
|
+
#
|
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".
|
10
|
+
#
|
11
|
+
# It contains task-specific data, timing information, and error records.
|
12
|
+
#
|
13
|
+
# All of its state is read-only.
|
14
|
+
#
|
15
|
+
# @author Robin Miller
|
16
|
+
#
|
17
|
+
# @!attribute [r] :id
|
18
|
+
# @return [Integer] the unique identifier for this task
|
19
|
+
# @!attribute [r] :run_at
|
20
|
+
# @return [Integer] Linux epoch timestamp of when to attempt this task next
|
21
|
+
# @!attribute [r] :initial_run_at
|
22
|
+
# @return [Integer] Linux epoch timestamp of the original value for run_at
|
23
|
+
# @!attribute [r] :expire_at
|
24
|
+
# @return [Integer] Linux epoch timestamp of when to consider this task obsolete
|
25
|
+
# @!attribute [r] :attempts
|
26
|
+
# @return [Integer] The number of times this task has been attempted
|
27
|
+
# @!attribute [r] :last_error
|
28
|
+
# @return [String] The message and stack trace of the error encountered on the most recent failed attempt
|
29
|
+
# @!attribute [r] :last_fail_at
|
30
|
+
# @return [Integer] Linux epoch timestamp of when the last_error was recorded
|
31
|
+
# @!attribute [r] :data
|
32
|
+
# @return [String] App-provided JSON data
|
33
|
+
class TaskMetaData
|
34
|
+
# These are the attributes expected to be in the persistence mechanism
|
35
|
+
EXPECTED_DATA = [:id, :run_at, :initial_run_at, :expire_at, :attempts, :last_error, :last_fail_at, :data].freeze
|
36
|
+
|
37
|
+
attr_reader(*EXPECTED_DATA, :queue)
|
38
|
+
|
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)
|
42
|
+
@id = id
|
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
|
48
|
+
@last_error = last_error
|
49
|
+
@last_fail_at = get_time(last_fail_at)
|
50
|
+
@data = data ? JSON.parse(data, symbolize_names: true) : nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def add_attempt
|
54
|
+
raise Task::AttemptsExhaustedError unless attempts_left?
|
55
|
+
|
56
|
+
@attempts += 1
|
57
|
+
end
|
58
|
+
|
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
|
73
|
+
end
|
74
|
+
|
75
|
+
def retryable?
|
76
|
+
attempts_left? && !expired?
|
77
|
+
end
|
78
|
+
|
79
|
+
def expired?
|
80
|
+
!@expire_at.nil? && @expire_at < Time.now
|
81
|
+
end
|
82
|
+
|
83
|
+
def attempts_left?
|
84
|
+
@queue.max_attempts.nil? || @attempts < @queue.max_attempts
|
85
|
+
end
|
86
|
+
|
87
|
+
def runnable?
|
88
|
+
!@run_at.nil? && @run_at <= Time.now
|
89
|
+
end
|
90
|
+
|
91
|
+
def successful?
|
92
|
+
raise 'you cannot check for success before running #work' if !expired? && @attempts <= 0
|
93
|
+
|
94
|
+
!expired? && @last_error.nil? && @last_fail_at.nil?
|
95
|
+
end
|
96
|
+
|
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)
|
104
|
+
|
105
|
+
@expire_at = expire_at if expire_at
|
106
|
+
|
107
|
+
if run_at
|
108
|
+
@run_at = @initial_run_at = get_time(run_at)
|
109
|
+
clear_fails
|
110
|
+
@attempts = 0
|
111
|
+
end
|
112
|
+
|
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?
|
118
|
+
end
|
119
|
+
|
120
|
+
def to_h
|
121
|
+
{id: @id,
|
122
|
+
queue: @queue.name.to_s,
|
123
|
+
run_at: @run_at,
|
124
|
+
initial_run_at: @initial_run_at,
|
125
|
+
expire_at: @expire_at,
|
126
|
+
attempts: @attempts,
|
127
|
+
last_fail_at: @last_fail_at,
|
128
|
+
last_error: @last_error,
|
129
|
+
data: serialized_data}
|
130
|
+
end
|
131
|
+
|
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
|
171
|
+
end
|
172
|
+
end
|