procrastinator 0.3.0 → 0.4.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 46f0ec23b44a55033b241f8f1eea274a99dcc481
4
- data.tar.gz: 84476d93786e0e63f53e11cbf913c8df9f641134
3
+ metadata.gz: 585203be626fab88b3799c54a970386cf8500995
4
+ data.tar.gz: 25b86adaa256bd8e64559237689849a3e1051e19
5
5
  SHA512:
6
- metadata.gz: 0f54aba2184d65a6dba03a756688565bfc6d45520ea521a0ce488353ea81c02ff1946d072145b176bc5ddc643f01670256bfb9b73819f70a7961d10f63ce1712
7
- data.tar.gz: 8181179a9437191dc43b34e61e67c7f846a83633962d7f3f41b1a69696b22769e85b5a15a1f8672c03af7c1f3fb8d0f3290fea6cef908e3bcf8b7da2128589b8
6
+ metadata.gz: 758e964a9edf01df9ae7e5d845614384c81cf76f053ac7939a5822bb2ca80bb088d3023df58025ece12ddd2f9a040711e324c6e7b084be8f07c48f4800732e0f
7
+ data.tar.gz: 0ade5c71cab428f01c0e8080e2fb5d8e867ab809b8c96c7cf81df8bd3fa36e390d066c7918a42bc4d7de6278b942264d3af1d9c2a7b1ab896fa8fff3cf27cd6d
data/README.md CHANGED
@@ -13,13 +13,9 @@ Add this line to your application's Gemfile:
13
13
  gem 'procrastinator'
14
14
  ```
15
15
 
16
- And then execute:
16
+ And then run:
17
17
 
18
- $ bundle
19
-
20
- Or install it yourself as:
21
-
22
- $ gem install procrastinator
18
+ bundle install
23
19
 
24
20
  ## Usage
25
21
  Setup a procrastination environment:
@@ -42,7 +38,7 @@ Read on for more details on each step.
42
38
 
43
39
  ### Setup Phase
44
40
  The setup phase first defines which queues are available and the persistence strategy to use for reading
45
- and writing tasks. It then starts up a sub process for working on each queue within that environment.
41
+ and writing tasks. It then spins off a sub process for working on each queue within that environment.
46
42
 
47
43
 
48
44
  #### Declaring a Persistence Strategy
@@ -98,6 +94,14 @@ Each queue is worked in a separate process.
98
94
 
99
95
  The sub-processes checks that the parent process is still alive every 5 seconds. If there is no process with the parent's PID, the sub-process will self-exit.
100
96
 
97
+ Sub-processes can be given a name prefix with the process_prefix method:
98
+
99
+ ```ruby
100
+ procrastinator = Procrastinator.setup(task_persister) do |env|
101
+ env.process_prefix('myapp')
102
+ end
103
+ ```
104
+
101
105
  ###Scheduling Tasks For Later
102
106
  Procrastinator will let you be lazy:
103
107
 
@@ -156,9 +160,9 @@ your task **must provide** a `#run` method:
156
160
 
157
161
  You may also optionally provide these hook methods, which are run during different points in the process:
158
162
 
159
- * `#success` - run after the task has completed successfully
160
- * `#fail` - run after the task has failed due to `#run` producing a `StandardError` or subclass.
161
- * `#final_fail` - run after the task has failed for the last time because either:
163
+ * `#success(logger)` - run after the task has completed successfully
164
+ * `#fail(logger, error)` - run after the task has failed due to `#run` producing a `StandardError` or subclass.
165
+ * `#final_fail(logger, error)` - run after the task has failed for the last time because either:
162
166
  1. the number of attempts is >= the `max_attempts` defined for the queue; or
163
167
  2. the time reported by `Time.now` is past the task's `expire_at` time.
164
168
 
@@ -166,8 +170,10 @@ If a task reaches `#final_fail` it will be marked to never be run again.
166
170
 
167
171
  ***Task Failure & Rescheduling***
168
172
 
169
- Tasks that fail have their `run_at` rescheduled on an increasing delay according to this formula:
170
- * 30 + attempts<sup>4</sup> **(in seconds)**
173
+ Tasks that fail have their `run_at` rescheduled on an increasing delay **(in seconds)** according to this formula:
174
+ * 30 + n<sup>4</sup>
175
+
176
+ n = the number of attempts
171
177
 
172
178
  Both failing and final_failing will cause the error timestamp and reason to be stored in `:last_fail_at` and `:last_error`.
173
179
 
@@ -200,6 +206,35 @@ env.act
200
206
  env.act(:cleanup, :email)
201
207
  ```
202
208
 
209
+ ### Logging
210
+ Logging is crucial to knowing what went wrong in an application after the fact, and because Procrastinator runs workers
211
+ in separate processes, providing a logger instance isn't really an option.
212
+
213
+ Instead, provide a directory that your Procrastinator instance should write log entries into:
214
+
215
+ ```ruby
216
+ procrastinator = Procrastinator.setup do |env|
217
+ env.log_dir('log/')
218
+ end
219
+ ```
220
+
221
+ Each worker creates its own log named after the queue it is working on (eg. `log/email-queue-worker.log`). The default
222
+ directory is `./log/`, relative to wherever the application is running. Logging will not occur at all if `log_dir` is
223
+ assigned a falsey value.
224
+
225
+ The logging level can be set using `log_level` and a value from the Ruby standard library
226
+ [Logger class](https://ruby-doc.org/stdlib-2.2.3/libdoc/logger/rdoc/Logger.html) (eg. `Logger::WARN`, `Logger::DEBUG`, etc.).
227
+
228
+ It logs process start at level `INFO`, process termination due to parent disppearance at level `ERROR` and task hooks
229
+ `#success`, `#fail`, and `#final_fail` are at a level `DEBUG`.
230
+
231
+ ```ruby
232
+ procrastinator = Procrastinator.setup do |env|
233
+ env.log_dir('log/')
234
+ env.log_level(Logger::INFO) # setting the default explicity
235
+ end
236
+ ```
237
+
203
238
  ## Contributing
204
239
  Bug reports and pull requests are welcome on GitHub at
205
240
  [https://github.com/TenjinInc/procrastinator](https://github.com/TenjinInc/procrastinator).
@@ -2,6 +2,7 @@ require 'procrastinator/version'
2
2
  require 'procrastinator/queue_worker'
3
3
  require 'procrastinator/task_worker'
4
4
  require 'procrastinator/environment'
5
+ require 'logger'
5
6
 
6
7
 
7
8
  module Procrastinator
@@ -2,6 +2,8 @@ module Procrastinator
2
2
  class Environment
3
3
  attr_reader :persister, :queue_definitions, :queue_workers, :processes, :test_mode
4
4
 
5
+ DEFAULT_LOG_DIRECTORY = 'log/'
6
+
5
7
  def initialize(persister:, test_mode: false)
6
8
  raise ArgumentError.new('persister cannot be nil') if persister.nil?
7
9
 
@@ -14,6 +16,8 @@ module Procrastinator
14
16
  @queue_definitions = {}
15
17
  @queue_workers = []
16
18
  @processes = []
19
+ @log_dir = DEFAULT_LOG_DIRECTORY
20
+ @log_level = Logger::INFO
17
21
  end
18
22
 
19
23
  def define_queue(name, properties={})
@@ -25,16 +29,23 @@ module Procrastinator
25
29
  def spawn_workers
26
30
  if @test_mode
27
31
  @queue_definitions.each do |name, props|
28
- @queue_workers << QueueWorker.new(props.merge(name: name, persister: @persister))
32
+ @queue_workers << QueueWorker.new(props.merge(name: name,
33
+ persister: @persister,
34
+ log_dir: @log_dir))
29
35
  end
30
36
  else
31
37
  @queue_definitions.each do |name, props|
32
38
  pid = fork do
33
- Process.setproctitle("#{name}-queue-worker")
39
+ worker = QueueWorker.new(props.merge(name: name,
40
+ persister: @persister,
41
+ log_dir: @log_dir,
42
+ log_level: @log_level))
43
+
44
+ worker.start_log
34
45
 
35
- worker = QueueWorker.new(props.merge(name: name, persister: @persister))
46
+ Process.setproctitle("#{@process_prefix ? "#{@process_prefix}-" : ''}#{worker.long_name}") # tODO: add an app name prefix
36
47
 
37
- monitor_parent
48
+ monitor_parent(worker)
38
49
 
39
50
  worker.work
40
51
  end
@@ -63,7 +74,17 @@ module Procrastinator
63
74
 
64
75
  def delay(queue: nil, run_at: Time.now.to_i, expire_at: nil, task:)
65
76
  raise ArgumentError.new('task may not be nil') if task.nil?
66
- raise MalformedTaskError.new('given task does not support #run method') unless task.respond_to? :run
77
+ raise MalformedTaskError.new('the provided task does not support #run method') unless task.respond_to? :run
78
+
79
+ # We're checking these on init because it's one of those extremely rare cases where you'd want to know
80
+ # incredibly early, because of the sub-processing. It's a bit belt-and suspenders, but UX is important for
81
+ # devs, too.
82
+ [:success, :fail, :final_fail].each do |method_name|
83
+ if task.respond_to?(method_name) && task.method(method_name).arity <= 0
84
+ raise MalformedTaskError.new("the provided task must accept a parameter to its ##{method_name} method")
85
+ end
86
+ end
87
+
67
88
  if queue.nil? && @queue_definitions.size > 1
68
89
  raise ArgumentError.new("queue must be specified when more than one is registered. Defined queues are: #{queue_definitions.keys.map { |k| ':' + k.to_s }.join(', ')}")
69
90
  else
@@ -82,11 +103,30 @@ module Procrastinator
82
103
  @test_mode = true
83
104
  end
84
105
 
106
+ def log_dir(path)
107
+ @log_dir = path
108
+ end
109
+
110
+ def log_level(lvl)
111
+ @log_level = lvl
112
+ end
113
+
114
+ def process_prefix(prefix)
115
+ @process_prefix = prefix
116
+ end
117
+
85
118
  private
86
- def monitor_parent
87
- heartbeat_thread = Thread.new(Process.ppid) do |ppid|
119
+ def monitor_parent(worker)
120
+ parent_pid = Process.ppid
121
+
122
+ heartbeat_thread = Thread.new(parent_pid) do |pid|
88
123
  loop do
89
- Process.kill 0, ppid
124
+ begin
125
+ Process.kill(0, pid) # kill(0) will check if the process exists
126
+ rescue Errno::ESRCH
127
+ worker.log_parent_exit
128
+ exit
129
+ end
90
130
 
91
131
  sleep(5)
92
132
  end
@@ -10,6 +10,8 @@ module Procrastinator
10
10
  # Timeout is in seconds
11
11
  def initialize(name:,
12
12
  persister:,
13
+ log_dir: nil,
14
+ log_level: Logger::INFO,
13
15
  max_attempts: DEFAULT_MAX_ATTEMPTS,
14
16
  timeout: DEFAULT_TIMEOUT,
15
17
  update_period: DEFAULT_UPDATE_PERIOD,
@@ -21,13 +23,14 @@ module Procrastinator
21
23
  raise(MalformedTaskPersisterError.new('The supplied IO object must respond to #update_task')) unless persister.respond_to? :update_task
22
24
  raise(MalformedTaskPersisterError.new('The supplied IO object must respond to #delete_task')) unless persister.respond_to? :delete_task
23
25
 
24
-
25
26
  @name = name.to_s.gsub(/\s/, '_').to_sym
26
27
  @timeout = timeout
27
28
  @max_attempts = max_attempts
28
29
  @update_period = update_period
29
30
  @max_tasks = max_tasks
30
31
  @persister = persister
32
+ @log_dir = log_dir
33
+ @log_level = log_level
31
34
  end
32
35
 
33
36
  def work
@@ -45,6 +48,8 @@ module Procrastinator
45
48
 
46
49
  tasks.first(@max_tasks).each do |task_data|
47
50
  if Time.now.to_i >= task_data[:run_at].to_i
51
+ task_data.merge!(logger: @logger) if @logger
52
+
48
53
  tw = TaskWorker.new(task_data)
49
54
 
50
55
  tw.work
@@ -57,6 +62,40 @@ module Procrastinator
57
62
  end
58
63
  end
59
64
  end
65
+
66
+ def long_name
67
+ "#{@name}-queue-worker"
68
+ end
69
+
70
+ # Starts a log file and stores the logger within this queue worker.
71
+ #
72
+ # Separate from init because logging is context-dependent
73
+ def start_log
74
+ if @log_dir
75
+ log_path = Pathname.new("#{@log_dir}/#{long_name}.log")
76
+
77
+ log_path.dirname.mkpath
78
+ File.open(log_path.to_path, 'a+') do |f|
79
+ f.write ''
80
+ end
81
+
82
+ @logger = Logger.new(log_path.to_path)
83
+
84
+ @logger.level = @log_level
85
+
86
+ @logger.info(['',
87
+ '===================================',
88
+ "Started worker process, #{long_name}, to work off queue #{@name}.",
89
+ "Worker pid=#{Process.pid}; parent pid=#{Process.ppid}.",
90
+ '==================================='].join("\n"))
91
+ end
92
+ end
93
+
94
+ def log_parent_exit
95
+ raise RuntimeError.new('Cannot log when logger not defined. Call #start_log first.') unless @logger
96
+
97
+ @logger.error("Terminated worker process (#{Process.pid}) due to main process (#{Process.ppid}) disappearing.")
98
+ end
60
99
  end
61
100
 
62
101
  class MalformedTaskPersisterError < StandardError
@@ -13,7 +13,8 @@ module Procrastinator
13
13
  max_attempts: nil,
14
14
  last_fail_at: nil,
15
15
  last_error: nil,
16
- task:)
16
+ task:,
17
+ logger: Logger.new(StringIO.new))
17
18
  @id = id
18
19
  @run_at = run_at.to_i
19
20
  @initial_run_at = initial_run_at.to_i
@@ -24,6 +25,7 @@ module Procrastinator
24
25
  @timeout = timeout
25
26
  @last_fail_at = last_fail_at
26
27
  @last_error = last_error
28
+ @logger = logger
27
29
 
28
30
  raise(MalformedTaskError.new('given task does not support #run method')) unless @task.respond_to? :run
29
31
  raise(ArgumentError.new('timeout cannot be negative')) if timeout && timeout < 0
@@ -39,21 +41,27 @@ module Procrastinator
39
41
  @task.run
40
42
  end
41
43
 
42
- try_hook(:success)
44
+ try_hook(:success, @logger)
45
+
46
+ @logger.debug("Task completed: #{YAML.dump(@task)}")
47
+
43
48
  @last_error = nil
44
49
  @last_fail_at = nil
45
50
  rescue StandardError => e
46
51
  @last_fail_at = Time.now.to_i
47
52
 
48
53
  if too_many_fails? || expired?
49
- try_hook(:final_fail, e)
54
+ try_hook(:final_fail, @logger, e)
50
55
 
51
56
  @run_at = nil
52
57
  @last_error = "#{expired? ? 'Task expired' : 'Task failed too many times'}: #{e.backtrace.join("\n")}"
58
+
59
+ @logger.debug("Task failed permanently: #{YAML.dump(@task)}")
53
60
  else
54
- try_hook(:fail, e)
61
+ try_hook(:fail, @logger, e)
55
62
 
56
63
  @last_error = %Q[Task failed: #{e.message}\n#{e.backtrace.join("\n")}]
64
+ @logger.debug("Task failed: #{YAML.dump(@task)}")
57
65
 
58
66
  reschedule
59
67
  end
@@ -1,3 +1,3 @@
1
1
  module Procrastinator
2
- VERSION = '0.3.0'
2
+ VERSION = '0.4.0'
3
3
  end
@@ -26,4 +26,5 @@ Gem::Specification.new do |spec|
26
26
  spec.add_development_dependency 'rspec', '~> 3.0'
27
27
  spec.add_development_dependency 'timecop', '~> 0.8'
28
28
  spec.add_development_dependency 'simplecov', '~> 0.11'
29
+ spec.add_development_dependency 'fakefs', '~> 0.10'
29
30
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: procrastinator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Miller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-06-12 00:00:00.000000000 Z
11
+ date: 2016-11-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0.11'
83
+ - !ruby/object:Gem::Dependency
84
+ name: fakefs
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.10'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.10'
83
97
  description: A strightforward job queue that is not dependent on Rails or any particular
84
98
  database or persistence mechanism.
85
99
  email: