procrastinator 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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: