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 +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:
|