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 +4 -4
- data/README.md +47 -12
- data/lib/procrastinator.rb +1 -0
- data/lib/procrastinator/environment.rb +48 -8
- data/lib/procrastinator/queue_worker.rb +40 -1
- data/lib/procrastinator/task_worker.rb +12 -4
- data/lib/procrastinator/version.rb +1 -1
- data/procrastinator.gemspec +1 -0
- metadata +16 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 585203be626fab88b3799c54a970386cf8500995
|
4
|
+
data.tar.gz: 25b86adaa256bd8e64559237689849a3e1051e19
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
16
|
+
And then run:
|
17
17
|
|
18
|
-
|
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
|
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 +
|
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).
|
data/lib/procrastinator.rb
CHANGED
@@ -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:
|
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
|
-
|
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
|
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('
|
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
|
-
|
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
|
-
|
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
|
data/procrastinator.gemspec
CHANGED
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.
|
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-
|
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:
|