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,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