procrastinator 0.9.0 → 1.0.0.pre.rc3

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.
@@ -54,7 +47,7 @@ module Procrastinator
54
47
  UpdateProxy.new(@config, identifier: identifier.merge(queue: queue.to_s))
55
48
  end
56
49
 
57
- # Removes an existing task, as located by the givne identifying information.
50
+ # Removes an existing task, as located by the given identifying information.
58
51
  #
59
52
  # The identifier can include any data field stored in the task loader. Often this is the information in :data.
60
53
  #
@@ -63,109 +56,335 @@ 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
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
109
182
  end
110
183
 
111
- def validate_run_at(run_at, saved_expire_at, expire_at)
112
- return unless run_at
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
113
197
 
114
- after_new_expire = expire_at && run_at.to_i > expire_at.to_i
198
+ log_devs = []
115
199
 
116
- raise "given run_at (#{ run_at }) is later than given expire_at (#{ expire_at })" if after_new_expire
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
117
203
 
118
- after_old_expire = saved_expire_at && run_at.to_i > saved_expire_at
204
+ multi = MultiIO.new(*log_devs)
205
+ multi.sync = true
119
206
 
120
- raise "given run_at (#{ run_at }) is later than saved expire_at (#{ saved_expire_at })" if after_old_expire
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
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
128
233
 
129
- tasks.first
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 and proceed as #threaded.
253
+ # Additional logging is recorded in the directory specified by the Procrastinator.setup configuration.
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
+ def daemonized!(pid_path = nil)
260
+ spawn_daemon(pid_path)
261
+
262
+ threaded
263
+ end
134
264
 
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
265
+ # Normalizes the given pid path, including conversion to absolute path and defaults.
266
+ #
267
+ # @param pid_path [Pathname, File, String, nil] path to normalize
268
+ def self.normalize_pid(pid_path)
269
+ normalized = Pathname.new(pid_path || DEFAULT_PID_DIR)
270
+ normalized /= "#{ PROG_NAME.downcase }#{ PID_EXT }" unless normalized.extname == PID_EXT
140
271
 
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
272
+ normalized.expand_path
273
+ end
145
274
 
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
275
+ # Stops the procrastinator process denoted by the provided pid file
276
+ def self.halt!(pid_path)
277
+ pid_path = normalize_pid pid_path
150
278
 
151
- def verify_queue_data!(queue_name, data)
152
- queue = @config.queue(name: queue_name)
279
+ Process.kill('TERM', pid_path.read.to_i)
280
+ end
281
+
282
+ def self.running?(pid_path)
283
+ pid = normalize_pid(pid_path).read.to_i
284
+
285
+ # this raises Errno::ESRCH when no process found, therefore if found we should exit
286
+ Process.getpgid pid
153
287
 
154
- unless queue
155
- queue_list = @config.queues_string
156
- raise ArgumentError, "there is no :#{ queue_name } queue registered. Defined queues are: #{ queue_list }"
288
+ true
289
+ rescue Errno::ENOENT, Errno::ESRCH
290
+ false
157
291
  end
158
292
 
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."
293
+ private
294
+
295
+ def spawn_daemon(pid_path)
296
+ pid_path = DaemonWorking.normalize_pid pid_path
297
+
298
+ open_log quiet: true
299
+ @logger.info "Starting #{ PROG_NAME } daemon..."
300
+
301
+ print_debug_context
302
+
303
+ # "You, search from the spastic dentistry department down through disembowelment.
304
+ # You, cover children's dance recitals through holiday weekend IKEA. Go."
305
+ Process.daemon
306
+
307
+ manage_pid pid_path
308
+ rename_process pid_path
309
+ rescue StandardError => e
310
+ @logger&.fatal ([e.message] + e.backtrace).join("\n")
311
+ raise e
312
+ end
313
+
314
+ def manage_pid(pid_path)
315
+ ensure_unique(pid_path)
316
+
317
+ @logger.debug "Managing pid at path: #{ pid_path }"
318
+ pid_path.dirname.mkpath
319
+ pid_path.write Process.pid.to_s
320
+
321
+ at_exit do
322
+ if pid_path.exist?
323
+ @logger.debug "Cleaning up pid file #{ pid_path }"
324
+ pid_path.delete
325
+ end
326
+ @logger.info "Procrastinator (pid #{ Process.pid }) halted."
162
327
  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
328
  end
329
+
330
+ def ensure_unique(pid_path)
331
+ return unless pid_path.exist?
332
+
333
+ @logger.debug "Checking pid file #{ pid_path }"
334
+
335
+ if DaemonWorking.running? pid_path
336
+ hint = 'Either terminate that process or remove the pid file (if coincidental).'
337
+ msg = "Another process (pid #{ pid_path.read }) already exists for #{ pid_path }. #{ hint }"
338
+ @logger.fatal msg
339
+ raise ProcessExistsError, msg
340
+ else
341
+ @logger.warn "Replacing old pid file of defunct process (pid #{ pid_path.read }) at #{ pid_path }."
342
+ end
343
+ end
344
+
345
+ def print_debug_context
346
+ @logger.debug "Ruby Path: #{ ENV.fetch 'RUBY_ROOT' }"
347
+ @logger.debug "Bundler Path: #{ ENV.fetch 'BUNDLE_BIN_PATH' }"
348
+ # LOGNAME is the posix standard and is set by cron, so probably reliable.
349
+ @logger.debug "Runtime User: #{ ENV.fetch('LOGNAME') || ENV.fetch('USERNAME') }"
350
+ end
351
+
352
+ def rename_process(pid_path)
353
+ name = pid_path.basename(PID_EXT).to_s
354
+
355
+ if name.size > MAX_PROC_LEN
356
+ @logger.warn "Process name is longer than max length (#{ MAX_PROC_LEN }). Trimming to fit."
357
+ name = name[0, MAX_PROC_LEN]
358
+ end
359
+
360
+ if system('pidof', name, out: File::NULL)
361
+ @logger.warn "Another process is already named '#{ name }'. Consider the 'name:' keyword to distinguish."
362
+ end
363
+
364
+ @logger.debug "Renaming process to: #{ name }"
365
+ Process.setproctitle name
366
+ end
367
+
368
+ include ThreadedWorking
169
369
  end
370
+
371
+ # DSL grammar object to enable chaining #work with the three work modes.
372
+ #
373
+ # @see Scheduler#work
374
+ class WorkProxy
375
+ include SerialWorking
376
+ include ThreadedWorking
377
+ include DaemonWorking
378
+
379
+ attr_reader :workers
380
+
381
+ def initialize(workers, config)
382
+ @workers = workers
383
+ @config = config
384
+ end
385
+ end
386
+ end
387
+
388
+ class ProcessExistsError < RuntimeError
170
389
  end
171
390
  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