procrastinator 0.6.1 → 1.0.0.pre.rc2
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 +5 -5
- data/.gitignore +6 -1
- data/.rubocop.yml +26 -0
- data/.ruby-version +1 -1
- data/Gemfile +2 -0
- data/README.md +492 -144
- data/RELEASE_NOTES.md +44 -0
- data/Rakefile +5 -3
- data/lib/procrastinator/config.rb +149 -0
- data/lib/procrastinator/logged_task.rb +50 -0
- data/lib/procrastinator/queue.rb +206 -0
- data/lib/procrastinator/queue_worker.rb +66 -91
- data/lib/procrastinator/rake/daemon_tasks.rb +54 -0
- data/lib/procrastinator/rake/tasks.rb +3 -0
- data/lib/procrastinator/scheduler.rb +393 -0
- data/lib/procrastinator/task.rb +64 -0
- data/lib/procrastinator/task_meta_data.rb +172 -0
- data/lib/procrastinator/task_store/file_transaction.rb +76 -0
- data/lib/procrastinator/task_store/simple_comma_store.rb +161 -0
- data/lib/procrastinator/test/mocks.rb +35 -0
- data/lib/procrastinator/version.rb +3 -1
- data/lib/procrastinator.rb +29 -23
- data/procrastinator.gemspec +17 -11
- metadata +66 -28
- data/lib/procrastinator/environment.rb +0 -148
- data/lib/procrastinator/task_worker.rb +0 -120
data/RELEASE_NOTES.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# Release Notes
|
2
|
+
|
3
|
+
## 1.0.0 ( )
|
4
|
+
|
5
|
+
### Major Changes
|
6
|
+
|
7
|
+
* Minimum supported Ruby is now 2.4
|
8
|
+
* Added generic `Procrastinator::Config#log_with`
|
9
|
+
* Removed `Procrastinator::Config#log_inside`
|
10
|
+
* Removed `Procrastinator::Config#log_at_level`
|
11
|
+
* falsey log level is now the control for whether logging occurs, instead of falsey log directory
|
12
|
+
* Queues are managed as threads rather than sub processes
|
13
|
+
* These unnecessary methods no longer exist:
|
14
|
+
* `Procrastinator.test_mode`
|
15
|
+
* `Procrastinator::Config#enable_test_mode`
|
16
|
+
* `Procrastinator::Config#test_mode?`
|
17
|
+
* `Procrastinator::Config#test_mode`
|
18
|
+
* `Procrastinator::Config#prefix`
|
19
|
+
* `Procrastinator::Config#pid_dir`
|
20
|
+
* `Procrastinator::Config#each_process`
|
21
|
+
* `Procrastinator::Config#run_process_block`
|
22
|
+
* Removed use of envvar `PROCRASTINATOR_STOP`
|
23
|
+
* `Procrastinator::QueueManager` is merged into `Procrastinator::Scheduler`
|
24
|
+
* Removed rake task to halt queue processes
|
25
|
+
* Renamed `Procrastinator::Config#provide_context` to `provide_container`
|
26
|
+
* You must now call `Scheduler#work` on the result of `Procrastinator.config`
|
27
|
+
* Use a dedicated process monitor (like `monit`) instead in production environments
|
28
|
+
* Suuply a block to `daemonized!` to run code in the spawned process.
|
29
|
+
* `max_tasks` is removed as it only added concurrency complexity
|
30
|
+
* Data is now stored as JSON instead of YAML
|
31
|
+
* Added with_store that applies its settings to its block
|
32
|
+
* `load_with` has been removed
|
33
|
+
* Removed `task_attr` and `Procrastinator::Task` module. Tasks is now duck-type checked for accessors instead.
|
34
|
+
|
35
|
+
### Minor Changes
|
36
|
+
|
37
|
+
* Started release notes file
|
38
|
+
* Updated development gems
|
39
|
+
* Logs now include the queue name in log lines
|
40
|
+
* Logs can now set the shift size or age (like Ruby's Logger)
|
41
|
+
|
42
|
+
### Bugfixes
|
43
|
+
|
44
|
+
* none
|
data/Rakefile
CHANGED
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
module Procrastinator
|
6
|
+
# Configuration object (State Pattern) used to coordinate settings across
|
7
|
+
# various components within Procrastinator.
|
8
|
+
#
|
9
|
+
# It is immutable after init; use the config DSL in the configuration block to set its state.
|
10
|
+
#
|
11
|
+
# @author Robin Miller
|
12
|
+
#
|
13
|
+
# @!attribute [r] :queues
|
14
|
+
# @return [Array] List of defined queues
|
15
|
+
# @!attribute [r] :container
|
16
|
+
# @return [Object] Container object that will be forwarded to tasks
|
17
|
+
# @!attribute [r] :log_dir
|
18
|
+
# @return [Pathname] Directory to write log files in
|
19
|
+
# @!attribute [r] :log_level
|
20
|
+
# @return [Integer] Logging level to use
|
21
|
+
# @!attribute [r] :log_shift_age
|
22
|
+
# @return [Integer] Number of previous files to keep (see Ruby Logger for details)
|
23
|
+
# @!attribute [r] :log_shift_size
|
24
|
+
# @return [Integer] Filesize before rotating to a new logfile (see Ruby Logger for details)
|
25
|
+
class Config
|
26
|
+
attr_reader :queues, :log_dir, :log_level, :log_shift_age, :log_shift_size, :container
|
27
|
+
|
28
|
+
DEFAULT_LOG_DIRECTORY = Pathname.new('log').freeze
|
29
|
+
DEFAULT_LOG_SHIFT_AGE = 0
|
30
|
+
DEFAULT_LOG_SHIFT_SIZE = 2 ** 20 # 1 MB
|
31
|
+
DEFAULT_LOG_FORMATTER = proc do |severity, datetime, progname, msg|
|
32
|
+
[datetime.iso8601(8),
|
33
|
+
severity,
|
34
|
+
"#{ progname } (#{ Process.pid }):",
|
35
|
+
msg].join("\t") << "\n"
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize
|
39
|
+
@queues = []
|
40
|
+
@container = nil
|
41
|
+
@log_dir = DEFAULT_LOG_DIRECTORY
|
42
|
+
@log_level = Logger::INFO
|
43
|
+
@log_shift_age = DEFAULT_LOG_SHIFT_AGE
|
44
|
+
@log_shift_size = DEFAULT_LOG_SHIFT_SIZE
|
45
|
+
|
46
|
+
with_store(csv: TaskStore::SimpleCommaStore::DEFAULT_FILE) do
|
47
|
+
if block_given?
|
48
|
+
yield(self)
|
49
|
+
raise SetupError, SetupError::ERR_NO_QUEUE if @queues.empty?
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
@log_dir = @log_dir.expand_path
|
54
|
+
|
55
|
+
@queues.freeze
|
56
|
+
freeze
|
57
|
+
end
|
58
|
+
|
59
|
+
# Collection of all of the methods intended for use within Procrastinator.setup
|
60
|
+
#
|
61
|
+
# @see Procrastinator
|
62
|
+
module DSL
|
63
|
+
# Assigns a task loader
|
64
|
+
def with_store(store)
|
65
|
+
raise(ArgumentError, 'with_store must be provided a block') unless block_given?
|
66
|
+
|
67
|
+
old_store = @default_store
|
68
|
+
@default_store = interpret_store(store)
|
69
|
+
yield
|
70
|
+
@default_store = old_store
|
71
|
+
end
|
72
|
+
|
73
|
+
def provide_container(container)
|
74
|
+
@container = container
|
75
|
+
end
|
76
|
+
|
77
|
+
def define_queue(name, task_class, properties = {})
|
78
|
+
raise ArgumentError, 'queue name cannot be nil' if name.nil?
|
79
|
+
raise ArgumentError, 'queue task class cannot be nil' if task_class.nil?
|
80
|
+
|
81
|
+
properties[:store] = interpret_store(properties[:store]) if properties.key? :store
|
82
|
+
|
83
|
+
@queues << Queue.new(**{name: name, task_class: task_class, store: @default_store}.merge(properties))
|
84
|
+
end
|
85
|
+
|
86
|
+
# Sets details of logging behaviour
|
87
|
+
#
|
88
|
+
# @param directory [Pathname,String] the directory to save logs within.
|
89
|
+
# @param level [Logger::UNKNOWN,Logger::FATAL,Logger::ERROR,Logger::WARN,Logger::INFO,Logger::DEBUG,Integer,Boolean] the Ruby Logger level to use. If falsey, no logging is performed.
|
90
|
+
# @param shift_age [Integer] number of old log files to keep (see Ruby Logger for details)
|
91
|
+
# @param shift_size [Integer] filesize before log is rotated to a fresh file (see Ruby Logger for details)
|
92
|
+
def log_with(directory: @log_dir, level: @log_level, shift_age: @log_shift_age, shift_size: @log_shift_size)
|
93
|
+
@log_dir = directory ? Pathname.new(directory) : directory
|
94
|
+
@log_level = level
|
95
|
+
@log_shift_age = shift_age
|
96
|
+
@log_shift_size = shift_size
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
include DSL
|
101
|
+
|
102
|
+
def queue(name: nil)
|
103
|
+
queue = if name
|
104
|
+
@queues.find do |q|
|
105
|
+
q.name == name
|
106
|
+
end
|
107
|
+
else
|
108
|
+
if name.nil? && @queues.length > 1
|
109
|
+
raise ArgumentError,
|
110
|
+
"queue must be specified when more than one is defined. #{ known_queues }"
|
111
|
+
end
|
112
|
+
|
113
|
+
@queues.first
|
114
|
+
end
|
115
|
+
|
116
|
+
raise ArgumentError, "there is no :#{ name } queue registered. #{ known_queues }" unless queue
|
117
|
+
|
118
|
+
queue
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def known_queues
|
124
|
+
"Known queues are: #{ @queues.map { |queue| ":#{ queue.name }" }.join(', ') }"
|
125
|
+
end
|
126
|
+
|
127
|
+
def interpret_store(store)
|
128
|
+
raise(ArgumentError, 'task store cannot be nil') if store.nil?
|
129
|
+
|
130
|
+
case store
|
131
|
+
when Hash
|
132
|
+
store_strategy = :csv
|
133
|
+
unless store.key? store_strategy
|
134
|
+
raise ArgumentError, "Must pass keyword :#{ store_strategy } if specifying a location for CSV file"
|
135
|
+
end
|
136
|
+
|
137
|
+
TaskStore::SimpleCommaStore.new(store[store_strategy])
|
138
|
+
when String, Pathname
|
139
|
+
TaskStore::SimpleCommaStore.new(store)
|
140
|
+
else
|
141
|
+
store
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
class SetupError < RuntimeError
|
146
|
+
ERR_NO_QUEUE = 'setup block must call #define_queue on the environment'
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'ostruct'
|
5
|
+
require 'timeout'
|
6
|
+
# require 'forwardable'
|
7
|
+
require 'delegate'
|
8
|
+
require_relative 'task'
|
9
|
+
|
10
|
+
module Procrastinator
|
11
|
+
# Task wrapper that adds logging to each step.
|
12
|
+
#
|
13
|
+
# @author Robin Miller
|
14
|
+
#
|
15
|
+
# @see Task
|
16
|
+
class LoggedTask < DelegateClass(Task)
|
17
|
+
# extend Forwardable
|
18
|
+
#
|
19
|
+
# def_delegators :@task, :id, :to_h
|
20
|
+
|
21
|
+
attr_reader :logger
|
22
|
+
|
23
|
+
alias task __getobj__
|
24
|
+
|
25
|
+
def initialize(task, logger: Logger.new(StringIO.new))
|
26
|
+
super task
|
27
|
+
@logger = logger || raise(ArgumentError, 'Logger cannot be nil')
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
task.run
|
32
|
+
|
33
|
+
begin
|
34
|
+
@logger.info("Task completed: #{ task }")
|
35
|
+
rescue StandardError => e
|
36
|
+
warn "Task logging error: #{ e.message }"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def fail(error)
|
41
|
+
hook = task.fail(error)
|
42
|
+
begin
|
43
|
+
@logger.error("Task #{ hook }ed: #{ task }")
|
44
|
+
rescue StandardError => e
|
45
|
+
warn "Task logging error: #{ e.message }"
|
46
|
+
end
|
47
|
+
hook
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Procrastinator
|
4
|
+
# A Queue defines how a certain type task will be processed.
|
5
|
+
#
|
6
|
+
# @author Robin Miller
|
7
|
+
#
|
8
|
+
# @!attribute [r] :name
|
9
|
+
# @return [Symbol] The queue's identifier symbol
|
10
|
+
# @!attribute [r] :task_class
|
11
|
+
# @return [Class] Class that defines the work to be done for jobs in this queue.
|
12
|
+
# @!attribute [r] :timeout
|
13
|
+
# @return [Object] Duration (seconds) after which tasks in this queue should fail for taking too long.
|
14
|
+
# @!attribute [r] :max_attempts
|
15
|
+
# @return [Object] Maximum number of attempts for tasks in this queue.
|
16
|
+
# @!attribute [r] :update_period
|
17
|
+
# @return [Pathname] Delay (seconds) between reloads of tasks from the task store.
|
18
|
+
class Queue
|
19
|
+
extend Forwardable
|
20
|
+
|
21
|
+
DEFAULT_TIMEOUT = 3600 # in seconds; one hour total
|
22
|
+
DEFAULT_MAX_ATTEMPTS = 20
|
23
|
+
DEFAULT_UPDATE_PERIOD = 10 # seconds
|
24
|
+
|
25
|
+
attr_reader :name, :max_attempts, :timeout, :update_period, :task_store, :task_class
|
26
|
+
|
27
|
+
alias store task_store
|
28
|
+
alias storage task_store
|
29
|
+
|
30
|
+
def_delegators :@task_store, :read, :update, :delete
|
31
|
+
|
32
|
+
# Timeout is in seconds
|
33
|
+
def initialize(name:, task_class:,
|
34
|
+
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
35
|
+
timeout: DEFAULT_TIMEOUT,
|
36
|
+
update_period: DEFAULT_UPDATE_PERIOD,
|
37
|
+
store: TaskStore::SimpleCommaStore.new)
|
38
|
+
raise ArgumentError, ':name cannot be nil' unless name
|
39
|
+
|
40
|
+
raise ArgumentError, ':task_class cannot be nil' unless task_class
|
41
|
+
raise ArgumentError, 'Task class must be initializable' unless task_class.respond_to? :new
|
42
|
+
|
43
|
+
raise ArgumentError, ':timeout cannot be negative' if timeout&.negative?
|
44
|
+
|
45
|
+
@name = name.to_s.strip.gsub(/[^A-Za-z0-9]+/, '_').to_sym
|
46
|
+
@task_class = task_class
|
47
|
+
@task_store = store
|
48
|
+
@max_attempts = max_attempts
|
49
|
+
@timeout = timeout
|
50
|
+
@update_period = update_period
|
51
|
+
|
52
|
+
validate!
|
53
|
+
|
54
|
+
freeze
|
55
|
+
end
|
56
|
+
|
57
|
+
def next_task(logger: Logger.new(StringIO.new), container: nil, scheduler: nil)
|
58
|
+
metadata = next_metas.find(&:runnable?)
|
59
|
+
|
60
|
+
return nil unless metadata
|
61
|
+
|
62
|
+
task = Task.new(metadata, task_handler(data: metadata.data,
|
63
|
+
container: container,
|
64
|
+
logger: logger,
|
65
|
+
scheduler: scheduler))
|
66
|
+
|
67
|
+
LoggedTask.new(task, logger: logger)
|
68
|
+
end
|
69
|
+
|
70
|
+
def fetch_task(identifier)
|
71
|
+
identifier[:data] = JSON.dump(identifier[:data]) if identifier[:data]
|
72
|
+
|
73
|
+
tasks = read(**identifier)
|
74
|
+
|
75
|
+
raise "no task found matching #{ identifier }" if tasks.nil? || tasks.empty?
|
76
|
+
raise "too many (#{ tasks.size }) tasks match #{ identifier }. Found: #{ tasks }" if tasks.size > 1
|
77
|
+
|
78
|
+
TaskMetaData.new(tasks.first.merge(queue: self))
|
79
|
+
end
|
80
|
+
|
81
|
+
def create(run_at:, expire_at:, data:)
|
82
|
+
if data.nil? && expects_data?
|
83
|
+
raise ArgumentError, "task #{ @task_class } expects to receive :data. Provide :data to #delay."
|
84
|
+
end
|
85
|
+
|
86
|
+
unless data.nil? || expects_data?
|
87
|
+
raise MalformedTaskError, <<~ERROR
|
88
|
+
found unexpected :data argument. Either do not provide :data when scheduling a task,
|
89
|
+
or add this in the #{ @task_class } class definition:
|
90
|
+
attr_accessor :data
|
91
|
+
ERROR
|
92
|
+
end
|
93
|
+
|
94
|
+
# TODO: shorten to using slice once updated to Ruby 2.5+
|
95
|
+
attrs = {queue: self, run_at: run_at, initial_run_at: run_at, expire_at: expire_at, data: JSON.dump(data)}
|
96
|
+
|
97
|
+
create_data = TaskMetaData.new(**attrs).to_h
|
98
|
+
create_data.delete(:id)
|
99
|
+
create_data.delete(:attempts)
|
100
|
+
create_data.delete(:last_fail_at)
|
101
|
+
create_data.delete(:last_error)
|
102
|
+
@task_store.create(**create_data)
|
103
|
+
end
|
104
|
+
|
105
|
+
def expects_data?
|
106
|
+
@task_class.method_defined?(:data=)
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def task_handler(data: nil, container: nil, logger: nil, scheduler: nil)
|
112
|
+
handler = @task_class.new
|
113
|
+
handler.data = data if handler.respond_to?(:data=)
|
114
|
+
handler.container = container
|
115
|
+
handler.logger = logger
|
116
|
+
handler.scheduler = scheduler
|
117
|
+
handler
|
118
|
+
end
|
119
|
+
|
120
|
+
def next_metas
|
121
|
+
tasks = read(queue: @name).reject { |t| t[:run_at].nil? }.collect do |t|
|
122
|
+
t.to_h.delete_if { |key| !TaskMetaData::EXPECTED_DATA.include?(key) }.merge(queue: self)
|
123
|
+
end
|
124
|
+
|
125
|
+
sort_tasks(tasks.collect { |t| TaskMetaData.new(**t) })
|
126
|
+
end
|
127
|
+
|
128
|
+
def sort_tasks(tasks)
|
129
|
+
# TODO: improve this
|
130
|
+
# shuffling and re-sorting to avoid worst case O(n^2) when receiving already sorted data
|
131
|
+
# on quicksort (which is default ruby sort). It is not unreasonable that the persister could return sorted
|
132
|
+
# results
|
133
|
+
# Ideally, we'd use a better algo than qsort for this, but this will do for now
|
134
|
+
tasks.shuffle.sort_by(&:run_at)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Internal queue validator
|
138
|
+
module QueueValidation
|
139
|
+
def validate!
|
140
|
+
verify_task_class!
|
141
|
+
verify_task_store!
|
142
|
+
end
|
143
|
+
|
144
|
+
def verify_task_class!
|
145
|
+
verify_run_method!
|
146
|
+
verify_accessors!
|
147
|
+
verify_hooks!
|
148
|
+
end
|
149
|
+
|
150
|
+
# The interface compliance is checked on init because it's one of those rare cases where you want to know early;
|
151
|
+
# otherwise, you wouldn't know until task execution and that could be far in the future.
|
152
|
+
# UX is important for devs, too.
|
153
|
+
# - R
|
154
|
+
def verify_run_method!
|
155
|
+
unless @task_class.method_defined? :run
|
156
|
+
raise MalformedTaskError, "task #{ @task_class } does not support #run method"
|
157
|
+
end
|
158
|
+
|
159
|
+
return unless @task_class.instance_method(:run).arity.positive?
|
160
|
+
|
161
|
+
raise MalformedTaskError, "task #{ @task_class } cannot require parameters to its #run method"
|
162
|
+
end
|
163
|
+
|
164
|
+
def verify_accessors!
|
165
|
+
[:logger, :container, :scheduler].each do |method_name|
|
166
|
+
next if @task_class.method_defined?(method_name) && @task_class.method_defined?("#{ method_name }=")
|
167
|
+
|
168
|
+
raise MalformedTaskError, <<~ERR
|
169
|
+
Task handler is missing a #{ method_name } accessor. Add this to the #{ @task_class } class definition:
|
170
|
+
attr_accessor :logger, :container, :scheduler
|
171
|
+
ERR
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def verify_hooks!
|
176
|
+
expected_arity = 1
|
177
|
+
|
178
|
+
[:success, :fail, :final_fail].each do |method_name|
|
179
|
+
next unless @task_class.method_defined?(method_name)
|
180
|
+
next if @task_class.instance_method(method_name).arity == expected_arity
|
181
|
+
|
182
|
+
err = "task #{ @task_class } must accept #{ expected_arity } parameter to its ##{ method_name } method"
|
183
|
+
|
184
|
+
raise MalformedTaskError, err
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def verify_task_store!
|
189
|
+
raise ArgumentError, ':store cannot be nil' if @task_store.nil?
|
190
|
+
|
191
|
+
[:read, :create, :update, :delete].each do |method|
|
192
|
+
unless @task_store.respond_to? method
|
193
|
+
raise MalformedTaskStoreError, "task store #{ @task_store.class } must respond to ##{ method }"
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
include QueueValidation
|
199
|
+
end
|
200
|
+
|
201
|
+
class MalformedTaskError < StandardError
|
202
|
+
end
|
203
|
+
|
204
|
+
class MalformedTaskStoreError < RuntimeError
|
205
|
+
end
|
206
|
+
end
|
@@ -1,116 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Procrastinator
|
4
|
+
# A QueueWorker checks for tasks to run from the task store and executes them, updating information in the task
|
5
|
+
# store as necessary.
|
6
|
+
#
|
7
|
+
# @author Robin Miller
|
2
8
|
class QueueWorker
|
3
|
-
|
4
|
-
DEFAULT_MAX_ATTEMPTS = 20
|
5
|
-
DEFAULT_UPDATE_PERIOD = 10 # seconds
|
6
|
-
DEFAULT_MAX_TASKS = 10
|
7
|
-
|
8
|
-
attr_reader :name, :timeout, :max_attempts, :update_period, :max_tasks
|
9
|
-
|
10
|
-
# Timeout is in seconds
|
11
|
-
def initialize(name:,
|
12
|
-
persister:,
|
13
|
-
log_dir: nil,
|
14
|
-
log_level: Logger::INFO,
|
15
|
-
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
16
|
-
timeout: DEFAULT_TIMEOUT,
|
17
|
-
update_period: DEFAULT_UPDATE_PERIOD,
|
18
|
-
max_tasks: DEFAULT_MAX_TASKS)
|
19
|
-
raise ArgumentError.new('Queue name may not be nil') unless name
|
20
|
-
raise ArgumentError.new('Persister may not be nil') unless persister
|
21
|
-
|
22
|
-
raise(MalformedTaskPersisterError.new('The supplied IO object must respond to #read_tasks')) unless persister.respond_to? :read_tasks
|
23
|
-
raise(MalformedTaskPersisterError.new('The supplied IO object must respond to #update_task')) unless persister.respond_to? :update_task
|
24
|
-
raise(MalformedTaskPersisterError.new('The supplied IO object must respond to #delete_task')) unless persister.respond_to? :delete_task
|
25
|
-
|
26
|
-
@name = name.to_s.gsub(/\s/, '_').to_sym
|
27
|
-
@timeout = timeout
|
28
|
-
@max_attempts = max_attempts
|
29
|
-
@update_period = update_period
|
30
|
-
@max_tasks = max_tasks
|
31
|
-
@persister = persister
|
32
|
-
@log_dir = log_dir
|
33
|
-
@log_level = log_level
|
34
|
-
|
35
|
-
start_log
|
36
|
-
end
|
9
|
+
extend Forwardable
|
37
10
|
|
38
|
-
|
39
|
-
begin
|
40
|
-
loop do
|
41
|
-
sleep(@update_period)
|
11
|
+
def_delegators :@queue, :name, :next_task
|
42
12
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
end
|
13
|
+
# expected methods for all persistence strategies
|
14
|
+
PERSISTER_METHODS = [:read, :update, :delete].freeze
|
15
|
+
|
16
|
+
def initialize(queue:, config:)
|
17
|
+
raise ArgumentError, ':queue cannot be nil' if queue.nil?
|
18
|
+
raise ArgumentError, ':config cannot be nil' if config.nil?
|
50
19
|
|
51
|
-
|
52
|
-
# shuffling and re-sorting to avoid worst case O(n^2) on quicksort (which is default ruby sort)
|
53
|
-
# when receiving already sorted data. Ideally, we'd use a better algo, but this will do for now
|
54
|
-
tasks = @persister.read_tasks(@name).reject { |t| t[:run_at].nil? }.shuffle.sort_by { |t| t[:run_at] }
|
20
|
+
@config = config
|
55
21
|
|
56
|
-
|
57
|
-
|
58
|
-
|
22
|
+
@queue = if queue.is_a? Symbol
|
23
|
+
config.queue(name: queue)
|
24
|
+
else
|
25
|
+
queue
|
26
|
+
end
|
27
|
+
|
28
|
+
@scheduler = Scheduler.new(config)
|
29
|
+
@logger = Logger.new(StringIO.new)
|
30
|
+
end
|
59
31
|
|
60
|
-
|
32
|
+
# Works on jobs forever
|
33
|
+
def work!
|
34
|
+
@logger = open_log!("#{ name }-queue-worker", @config)
|
35
|
+
@logger.info("Started worker thread to consume queue: #{ name }")
|
61
36
|
|
62
|
-
|
37
|
+
loop do
|
38
|
+
sleep(@queue.update_period)
|
63
39
|
|
64
|
-
|
65
|
-
@persister.delete_task(task_data[:id])
|
66
|
-
else
|
67
|
-
@persister.update_task(tw.to_hash.merge(queue: @name))
|
68
|
-
end
|
69
|
-
end
|
40
|
+
work_one
|
70
41
|
end
|
71
|
-
|
42
|
+
rescue StandardError => e
|
43
|
+
@logger.fatal(e)
|
72
44
|
|
73
|
-
|
74
|
-
"#{@name}-queue-worker"
|
45
|
+
raise
|
75
46
|
end
|
76
47
|
|
77
|
-
#
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
log_path = Pathname.new("#{@log_dir}/#{long_name}.log")
|
83
|
-
|
84
|
-
log_path.dirname.mkpath
|
85
|
-
File.open(log_path.to_path, 'a+') do |f|
|
86
|
-
f.write ''
|
87
|
-
end
|
48
|
+
# Performs exactly one task on the queue
|
49
|
+
def work_one
|
50
|
+
task = next_task(logger: @logger,
|
51
|
+
container: @config.container,
|
52
|
+
scheduler: @scheduler) || return
|
88
53
|
|
89
|
-
|
54
|
+
begin
|
55
|
+
task.run
|
90
56
|
|
91
|
-
@
|
57
|
+
@queue.delete(task.id)
|
58
|
+
rescue StandardError => e
|
59
|
+
task.fail(e)
|
92
60
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
"Worker pid=#{Process.pid}; parent pid=#{Process.ppid}.",
|
97
|
-
'==================================='].join("\n"))
|
61
|
+
task_info = task.to_h
|
62
|
+
id = task_info.delete(:id)
|
63
|
+
@queue.update(id, **task_info)
|
98
64
|
end
|
99
65
|
end
|
100
66
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
#
|
107
|
-
def
|
108
|
-
|
67
|
+
def halt
|
68
|
+
@logger&.info("Halted worker on queue: #{ name }")
|
69
|
+
@logger&.close
|
70
|
+
end
|
71
|
+
|
72
|
+
# Starts a log file and returns the created Logger
|
73
|
+
def open_log!(name, config)
|
74
|
+
return @logger unless config.log_level
|
75
|
+
|
76
|
+
log_path = config.log_dir / "#{ name }.log"
|
77
|
+
|
78
|
+
config.log_dir.mkpath
|
79
|
+
FileUtils.touch(log_path)
|
109
80
|
|
110
|
-
|
81
|
+
Logger.new(log_path.to_path,
|
82
|
+
config.log_shift_age, config.log_shift_size,
|
83
|
+
level: config.log_level || Logger::FATAL,
|
84
|
+
progname: name,
|
85
|
+
formatter: Config::DEFAULT_LOG_FORMATTER)
|
111
86
|
end
|
112
87
|
end
|
113
88
|
|
114
89
|
class MalformedTaskPersisterError < StandardError
|
115
90
|
end
|
116
|
-
end
|
91
|
+
end
|