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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'stringio'
4
+
3
5
  module Procrastinator
4
6
  # A Scheduler object provides the API for client applications to manage delayed tasks.
5
7
  #
@@ -7,33 +9,24 @@ module Procrastinator
7
9
  #
8
10
  # @author Robin Miller
9
11
  class Scheduler
10
- extend Forwardable
11
-
12
- def_delegators :@queue_manager, :act
13
-
14
- def initialize(config, queue_manager)
15
- @config = config
16
- @queue_manager = queue_manager
12
+ def initialize(config)
13
+ @config = config
17
14
  end
18
15
 
19
16
  # Records a new task to be executed at the given time.
20
17
  #
21
- # @param queue [Symbol] the symbol identifier for the queue to add a new task on
18
+ # @param queue_name [Symbol] the symbol identifier for the queue to add a new task on
22
19
  # @param run_at [Time, Integer] Optional time when this task should be executed. Defaults to the current time.
23
- # @param data [Hash, Array] Optional simple data object to be provided to the task upon execution.
20
+ # @param data [Hash, Array, String, Integer] Optional simple data object to be provided to the task on execution.
24
21
  # @param expire_at [Time, Integer] Optional time when the task should be abandoned
25
- def delay(queue = nil, data: nil, run_at: Time.now.to_i, expire_at: nil)
26
- verify_queue_arg!(queue)
27
-
28
- queue = @config.queue.name if @config.single_queue?
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
29
26
 
30
- verify_queue_data!(queue, data)
27
+ queue = @config.queue(name: queue_name)
31
28
 
32
- loader.create(queue: queue.to_s,
33
- run_at: run_at.to_i,
34
- initial_run_at: run_at.to_i,
35
- expire_at: expire_at.nil? ? nil : expire_at.to_i,
36
- data: YAML.dump(data))
29
+ queue.create(run_at: run_at, expire_at: expire_at, data: data)
37
30
  end
38
31
 
39
32
  # Alters an existing task to run at a new time, expire at a new time, or both.
@@ -63,109 +56,338 @@ module Procrastinator
63
56
  #
64
57
  # @see TaskMetaData
65
58
  def cancel(queue, identifier)
66
- tasks = loader.read(identifier.merge(queue: queue.to_s))
59
+ queue = @config.queue(name: queue)
60
+
61
+ tasks = queue.read(identifier.merge(queue: queue.name.to_s))
67
62
 
68
63
  raise "no task matches search: #{ identifier }" if tasks.empty?
69
64
  raise "multiple tasks match search: #{ identifier }" if tasks.size > 1
70
65
 
71
- loader.delete(tasks.first[:id])
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)
72
81
  end
73
82
 
74
83
  # Provides a more natural syntax for rescheduling tasks
75
84
  #
76
85
  # @see Scheduler#reschedule
77
86
  class UpdateProxy
78
- def initialize(config, identifier:)
79
- identifier[:data] = YAML.dump(identifier[:data]) if identifier[:data]
80
-
81
- @config = config
87
+ def initialize(queue, identifier:)
88
+ @queue = queue
82
89
  @identifier = identifier
83
90
  end
84
91
 
85
92
  def to(run_at: nil, expire_at: nil)
86
- task = fetch_task(@identifier)
87
-
88
- verify_time_provided(run_at, expire_at)
89
- validate_run_at(run_at, task[:expire_at], expire_at)
93
+ task = @queue.fetch_task(@identifier)
90
94
 
91
- new_data = {
92
- attempts: 0,
93
- last_error: nil,
94
- last_error_at: nil
95
- }
95
+ raise ArgumentError, 'you must provide at least :run_at or :expire_at' if run_at.nil? && expire_at.nil?
96
96
 
97
- new_data = new_data.merge(run_at: run_at.to_i, initial_run_at: run_at.to_i) if run_at
98
- new_data = new_data.merge(expire_at: expire_at.to_i) if expire_at
97
+ task.reschedule(expire_at: expire_at) if expire_at
98
+ task.reschedule(run_at: run_at) if run_at
99
99
 
100
- @config.loader.update(task[:id], new_data)
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)
101
104
  end
102
105
 
103
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
104
150
 
105
151
  private
106
152
 
107
- def verify_time_provided(run_at, expire_at)
108
- raise ArgumentError, 'you must provide at least :run_at or :expire_at' if run_at.nil? && expire_at.nil?
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
109
169
  end
110
170
 
111
- def validate_run_at(run_at, saved_expire_at, expire_at)
112
- return unless run_at
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
113
175
 
114
- after_new_expire = expire_at && run_at.to_i > expire_at.to_i
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
115
183
 
116
- raise "given run_at (#{ run_at }) is later than given expire_at (#{ expire_at })" if after_new_expire
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
117
190
 
118
- after_old_expire = saved_expire_at && run_at.to_i > saved_expire_at
191
+ def shutdown!
192
+ (@threads || []).select(&:alive?).each(&:kill)
193
+ end
119
194
 
120
- raise "given run_at (#{ run_at }) is later than saved expire_at (#{ saved_expire_at })" if after_old_expire
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)
121
211
  end
122
212
 
123
- def fetch_task(identifier)
124
- tasks = @config.loader.read(identifier)
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
125
219
 
126
- raise "no task found matching #{ identifier }" if tasks.nil? || tasks.empty?
127
- raise "too many (#{ tasks.size }) tasks match #{ identifier }. Found: #{ tasks }" if tasks.size > 1
220
+ # IO Multiplexer that forwards calls to a list of IO streams.
221
+ class MultiIO
222
+ def initialize(*stream)
223
+ @streams = stream
224
+ end
128
225
 
129
- tasks.first
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
130
239
  end
131
240
  end
132
241
 
133
- private
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
134
265
 
135
- # Scheduler must always get the loader indirectly. If it saves the loader to an instance variable,
136
- # then that could hold a reference to a bad (ie. gone) connection on the previous process
137
- def loader
138
- @config.loader
139
- end
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
140
272
 
141
- def verify_queue_arg!(queue_name)
142
- raise ArgumentError, <<~ERR if !queue_name.nil? && !queue_name.is_a?(Symbol)
143
- must provide a queue name as the first argument. Received: #{ queue_name }
144
- ERR
273
+ pid_path.expand_path
274
+ end
145
275
 
146
- raise ArgumentError, <<~ERR if queue_name.nil? && !@config.single_queue?
147
- queue must be specified when more than one is registered. Defined queues are: #{ @config.queues_string }
148
- ERR
149
- end
276
+ # Stops the procrastinator process denoted by the provided pid file
277
+ def self.halt!(pid_path)
278
+ pid_path = normalize_pid pid_path
150
279
 
151
- def verify_queue_data!(queue_name, data)
152
- queue = @config.queue(name: queue_name)
280
+ Process.kill('TERM', pid_path.read.to_i)
281
+ end
153
282
 
154
- unless queue
155
- queue_list = @config.queues_string
156
- raise ArgumentError, "there is no :#{ queue_name } queue registered. Defined queues are: #{ queue_list }"
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
157
292
  end
158
293
 
159
- if data.nil?
160
- if queue.task_class.method_defined?(:data=)
161
- raise ArgumentError, "task #{ queue.task_class } expects to receive :data. Provide :data to #delay."
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."
162
330
  end
163
- elsif !queue.task_class.method_defined?(:data=)
164
- raise ArgumentError, <<~ERROR
165
- task #{ queue.task_class } does not import :data. Add this in your class definition:
166
- task_attr :data
167
- ERROR
168
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
169
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
170
392
  end
171
393
  end
@@ -1,46 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'forwardable'
4
+ require 'time'
5
+
3
6
  module Procrastinator
4
- # Module to be included by user-defined task classes. It provides some extra error checking and a convenient way
5
- # for the task class to access additional information (data, logger, etc) from Procrastinator.
6
- #
7
- # If you are averse to including this in your task class, you can just declare an attr_accessor for the
8
- # information you want Procrastinator to feed your task.
7
+ # Wraps a task handler and task metadata
9
8
  #
10
9
  # @author Robin Miller
11
- module Task
12
- KNOWN_ATTRIBUTES = [:logger, :context, :data, :scheduler].freeze
10
+ class Task
11
+ extend Forwardable
13
12
 
14
- def self.included(base)
15
- base.extend(TaskClassMethods)
16
- end
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
17
18
 
18
- def respond_to_missing?(name, include_private)
19
- super
19
+ def initialize(metadata, handler)
20
+ @metadata = metadata
21
+ @handler = handler
20
22
  end
21
23
 
22
- def method_missing(method_name, *args, &block)
23
- if KNOWN_ATTRIBUTES.include?(method_name)
24
- raise NameError, "To access Procrastinator::Task attribute :#{ method_name }, " \
25
- "call task_attr(:#{ method_name }) in your class definition."
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
26
30
  end
31
+ @metadata.clear_fails
27
32
 
28
- super
33
+ try_hook(:success, result)
29
34
  end
30
35
 
31
- # Module that provides the task_attr class method for task definitions to declare their expected information.
32
- module TaskClassMethods
33
- def task_attr(*fields)
34
- attr_list = KNOWN_ATTRIBUTES.collect { |a| ':' + a.to_s }.join(', ')
36
+ alias call run
35
37
 
36
- fields.each do |field|
37
- err = "Unknown Procrastinator::Task attribute :#{ field }. " \
38
- "Importable attributes are: #{ attr_list }"
39
- raise ArgumentError, err unless KNOWN_ATTRIBUTES.include?(field)
40
- end
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)
41
43
 
42
- attr_accessor(*fields)
43
- end
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
44
62
  end
45
63
  end
46
64
  end