herdst_worker 0.1.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 +7 -0
- data/Rakefile +7 -0
- data/bin/herdst_worker +16 -0
- data/lib/herdst_worker.rb +8 -0
- data/lib/herdst_worker/adapters/database.rb +31 -0
- data/lib/herdst_worker/adapters/facade.rb +20 -0
- data/lib/herdst_worker/adapters/sentry.rb +26 -0
- data/lib/herdst_worker/application/facade.rb +88 -0
- data/lib/herdst_worker/autoload/facade.rb +67 -0
- data/lib/herdst_worker/configuration/facade.rb +51 -0
- data/lib/herdst_worker/configuration/metadata.rb +183 -0
- data/lib/herdst_worker/configuration/paths.rb +41 -0
- data/lib/herdst_worker/log/facade.rb +87 -0
- data/lib/herdst_worker/queue/facade.rb +162 -0
- data/lib/herdst_worker/queue/processor.rb +223 -0
- data/lib/herdst_worker/queue/runner.rb +137 -0
- data/lib/herdst_worker/signals/facade.rb +35 -0
- data/lib/herdst_worker/version.rb +3 -0
- metadata +272 -0
@@ -0,0 +1,41 @@
|
|
1
|
+
module HerdstWorker
|
2
|
+
module Configuration
|
3
|
+
class Paths
|
4
|
+
|
5
|
+
|
6
|
+
attr_accessor :root_path, :paths
|
7
|
+
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
self.root_path = ENV["ROOT_PATH"] || Dir.pwd
|
11
|
+
|
12
|
+
self.paths = ActiveSupport::HashWithIndifferentAccess.new
|
13
|
+
self.paths[:root] = self.root_path
|
14
|
+
self.paths[:app] = "#{self.root_path}/app"
|
15
|
+
self.paths[:config] = "#{self.root_path}/config"
|
16
|
+
self.paths[:temp] = "#{self.root_path}/tmp"
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
def []=(name, value)
|
21
|
+
self.paths[name] = "#{self.root_path}/#{value}"
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
def [](name)
|
26
|
+
self.paths[name]
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def method_missing(name)
|
31
|
+
if self.paths.include?(name)
|
32
|
+
self.paths[name]
|
33
|
+
else
|
34
|
+
raise NoMethodError
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
|
4
|
+
module HerdstWorker
|
5
|
+
module Log
|
6
|
+
|
7
|
+
|
8
|
+
DEFAULT_PROGNAME = "application"
|
9
|
+
|
10
|
+
|
11
|
+
class Facade
|
12
|
+
|
13
|
+
|
14
|
+
attr_accessor :logger, :progname, :single_line, :level
|
15
|
+
|
16
|
+
|
17
|
+
def initialize(level = "DEBUG", progname = DEFAULT_PROGNAME, single_line = false)
|
18
|
+
self.progname = progname
|
19
|
+
self.single_line = single_line
|
20
|
+
|
21
|
+
self.logger = Logger.new(STDOUT)
|
22
|
+
self.logger.progname = self.get_progname(progname)
|
23
|
+
self.set_log_level(level)
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def set_log_level(level)
|
28
|
+
self.level = level
|
29
|
+
self.logger.level = Logger.const_get(level)
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
def debug(*attributes)
|
34
|
+
write_message :debug, attributes
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
def info(*attributes)
|
39
|
+
write_message :info, attributes
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
def warn(*attributes)
|
44
|
+
write_message :warn, attributes
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
def error(*attributes)
|
49
|
+
write_message :error, attributes
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def fatal(*attributes)
|
54
|
+
write_message :fatal, attributes
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
def method_missing(name)
|
59
|
+
full_name = self.progname === DEFAULT_PROGNAME ?
|
60
|
+
name :
|
61
|
+
"#{self.progname}.#{name}"
|
62
|
+
|
63
|
+
Facade.new(self.level, full_name, self.single_line)
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
def write_message(type, message)
|
68
|
+
if self.single_line
|
69
|
+
full_message = message.map { |i| i.to_s }.join(" ")
|
70
|
+
else
|
71
|
+
full_message = message.map { |i| i.to_s }.join("\n")
|
72
|
+
end
|
73
|
+
|
74
|
+
self.logger.send(type, full_message)
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
protected
|
79
|
+
def get_progname(name)
|
80
|
+
"[#{name.upcase}]"
|
81
|
+
end
|
82
|
+
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require_relative 'processor'
|
3
|
+
|
4
|
+
|
5
|
+
module HerdstWorker
|
6
|
+
module Queue
|
7
|
+
class Facade
|
8
|
+
|
9
|
+
|
10
|
+
attr_accessor :app, :processor
|
11
|
+
|
12
|
+
|
13
|
+
def initialize(app, enabled, url, queue_wait_time)
|
14
|
+
self.app = app
|
15
|
+
self.processor = HerdstWorker::Queue::Processor.new(app, enabled, url, queue_wait_time)
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def get_status
|
20
|
+
self.processor.processor_status
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def start
|
25
|
+
self.processor.start
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def halt
|
30
|
+
self.processor.halt
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
def stop
|
35
|
+
self.processor.stop
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
def get_processor
|
40
|
+
self.processor
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
def send_message(type, company_id, user_id, data = {}, queue = :primary, attributes = nil, delay = nil)
|
45
|
+
client = get_queue_client(queue)
|
46
|
+
message = create_queue_message(type, company_id, user_id, data, attributes, delay)
|
47
|
+
message.delete(:id)
|
48
|
+
|
49
|
+
client.send_message(message)
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def send_messages(messages = [], queue = :primary)
|
54
|
+
client = get_queue_client(queue)
|
55
|
+
|
56
|
+
messages.each_slice(10) do |messages_group|
|
57
|
+
client.send_messages({ :entries => messages_group })
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
def send_email_message(template, company, data = {}, attributes = nil, delay = nil)
|
63
|
+
client = get_queue_client(:notifications)
|
64
|
+
|
65
|
+
message = create_email_queue_message(template, company, data, attributes, delay)
|
66
|
+
message.delete(:id)
|
67
|
+
|
68
|
+
client.send_message(message)
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
def create_queue_message(type, company_id, user_id, data, attributes = nil, delay = nil)
|
73
|
+
message = Hash.new
|
74
|
+
message[:id] = SecureRandom.hex(32)
|
75
|
+
message[:message_body] = Hash.new
|
76
|
+
message[:message_body][:eventVersion] = 1.0
|
77
|
+
message[:message_body][:eventSource] = "application:Que"
|
78
|
+
message[:message_body][:eventTime] = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
79
|
+
message[:message_body][:eventName] = type.to_s.camelize(:lower)
|
80
|
+
message[:message_body][:userIdentity] = { :principalId => user_id, :companyId => company_id }
|
81
|
+
message[:message_body][:Que] = data
|
82
|
+
message[:message_body][:Que][:configurationId] = message[:message_body][:eventName]
|
83
|
+
|
84
|
+
message[:message_body] = { :Records => [message[:message_body]] }.to_json
|
85
|
+
message[:message_attributes] = attributes if attributes
|
86
|
+
message[:delay_seconds] = delay if delay
|
87
|
+
message
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
def create_email_queue_message(template, company, message_data, attributes = nil, delay = 2)
|
92
|
+
# Convert instances of ApplicationRecord to attributes instead of json representation
|
93
|
+
message_data.each do |key, value|
|
94
|
+
message_data[key] = value.attributes if value.is_a?(ActiveRecord::Base)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Build data
|
98
|
+
data = Hash.new
|
99
|
+
data[:app] = self.app.name
|
100
|
+
data[:app_id] = company.id
|
101
|
+
data[:app_name] = company.name
|
102
|
+
data[:app_slug] = company.get_host_name
|
103
|
+
data[:app_brand_id] = company.brand_id
|
104
|
+
data[:app_host] = company.get_url
|
105
|
+
data[:type] = "email"
|
106
|
+
data[:template] = template
|
107
|
+
data[:data] = message_data
|
108
|
+
|
109
|
+
# Add message structure
|
110
|
+
message = Hash.new
|
111
|
+
message[:id] = SecureRandom.hex(32)
|
112
|
+
message[:message_body] = data.to_json
|
113
|
+
message[:message_attributes] = attributes if attributes
|
114
|
+
message[:delay_seconds] = delay
|
115
|
+
message
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
def create_notification_message(message_data, attributes = nil, expiry = nil, delay = nil)
|
120
|
+
message = Hash.new
|
121
|
+
message[:id] = SecureRandom.hex(32)
|
122
|
+
message[:message_body] = Hash.new
|
123
|
+
message[:message_body][:Type] = "Notification"
|
124
|
+
message[:message_body][:Message] = message_data.to_json
|
125
|
+
message[:message_body] = message[:message_body].to_json
|
126
|
+
message[:message_attributes] = attributes ? attributes : Hash.new
|
127
|
+
message[:message_attributes]["expiry"] = { :string_value => expiry.to_s, :data_type => "Number" } if expiry
|
128
|
+
message[:delay_seconds] = delay if delay
|
129
|
+
|
130
|
+
message.delete(:message_attributes) if message[:message_attributes].size == 0
|
131
|
+
|
132
|
+
message
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
def get_queue_client(queue_name)
|
137
|
+
queue_url = self.app.queues[queue_name]
|
138
|
+
sqs_client = Aws::SQS::Client.new(
|
139
|
+
:credentials => self.app.config.metadata.get_aws_credentials
|
140
|
+
)
|
141
|
+
|
142
|
+
Aws::SQS::Queue.new(
|
143
|
+
:url => queue_url,
|
144
|
+
:client => sqs_client
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
def self.get_delay(index)
|
150
|
+
messages_per_second = 10
|
151
|
+
send_time_per_message = 0.4
|
152
|
+
|
153
|
+
offset = (index / messages_per_second)
|
154
|
+
delay = (offset * (messages_per_second * send_time_per_message)).to_i
|
155
|
+
|
156
|
+
delay > 900 ? 900 : delay
|
157
|
+
end
|
158
|
+
|
159
|
+
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
require 'aws-sdk-sqs'
|
2
|
+
|
3
|
+
require_relative 'runner'
|
4
|
+
|
5
|
+
|
6
|
+
module HerdstWorker
|
7
|
+
module Queue
|
8
|
+
class Processor < Runner
|
9
|
+
|
10
|
+
|
11
|
+
attr_accessor :app, :enabled, :queue_url, :queue_wait_time, :poller
|
12
|
+
attr_accessor :start_time, :restart_time
|
13
|
+
attr_accessor :processor_status, :job_count, :max_jobs
|
14
|
+
attr_accessor :attempt_threshold, :visibility_timeout, :ignored_notifications
|
15
|
+
|
16
|
+
|
17
|
+
def initialize(app, enabled, queue_url, queue_wait_time)
|
18
|
+
self.app = app
|
19
|
+
self.enabled = enabled
|
20
|
+
self.queue_url = queue_url
|
21
|
+
self.queue_wait_time = queue_wait_time
|
22
|
+
self.poller = Aws::SQS::QueuePoller.new(queue_url)
|
23
|
+
self.job_count = 0
|
24
|
+
self.max_jobs = 10
|
25
|
+
self.attempt_threshold = 6
|
26
|
+
self.visibility_timeout = 15
|
27
|
+
self.ignored_notifications = [
|
28
|
+
"AmazonSnsSubscriptionSucceeded"
|
29
|
+
]
|
30
|
+
|
31
|
+
# Set the start time
|
32
|
+
self.reset_time
|
33
|
+
|
34
|
+
# Start the processor as working
|
35
|
+
self.set_status "starting"
|
36
|
+
|
37
|
+
# Log queue stats
|
38
|
+
self.poller.before_request do |stats|
|
39
|
+
before_request(stats)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
# Runs the poller
|
45
|
+
def start_poller
|
46
|
+
if self.enabled
|
47
|
+
self.poller.poll(:wait_time_seconds => self.queue_wait_time, :skip_delete => false) do |msg|
|
48
|
+
process_message(msg)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
# Starts or resets the application to a working status
|
55
|
+
def start
|
56
|
+
if self.processor_status == "starting"
|
57
|
+
self.set_status "working"
|
58
|
+
self.reset_time
|
59
|
+
self.start_poller
|
60
|
+
else
|
61
|
+
return if self.processor_status == "working"
|
62
|
+
|
63
|
+
self.set_status "working"
|
64
|
+
self.reset_time
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
# Sets the processor status to finishing. The sqs before action will
|
70
|
+
# take care of setting the idle state once all jobs have finished.
|
71
|
+
def halt
|
72
|
+
return if self.processor_status === "finishing"
|
73
|
+
set_status "finishing"
|
74
|
+
end
|
75
|
+
|
76
|
+
|
77
|
+
# Sets the processor status to stopping. The sqs before action will
|
78
|
+
# take care of stopping the application once all jobs have finished.
|
79
|
+
def stop
|
80
|
+
return if self.processor_status == "stopping"
|
81
|
+
set_status "stopping"
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
# Set the processor status. The status is alos logged to file so services
|
86
|
+
# like capastranio can see the current status
|
87
|
+
def set_status(status)
|
88
|
+
statuses = ["starting", "idle", "working", "finishing", "stopping", "stopped"]
|
89
|
+
|
90
|
+
if statuses.include? status
|
91
|
+
# Set status
|
92
|
+
self.processor_status = status
|
93
|
+
|
94
|
+
# Write the current status to file for capastranio to use
|
95
|
+
process_file = self.app.config.paths.temp + "/process_status"
|
96
|
+
File.open(process_file, "w") { |file| file.write(status) }
|
97
|
+
else
|
98
|
+
raise "Invalid status (#{status})"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
def before_request(stats)
|
104
|
+
if self.app.config.is_dev?
|
105
|
+
self.app.logger.queue_stats.info "STATS (#{self.processor_status}): #{stats.inspect}"
|
106
|
+
end
|
107
|
+
|
108
|
+
# After 1 hour of running terminate application.
|
109
|
+
# The app will automatically restart in production
|
110
|
+
current_time = Time.now.utc.to_i
|
111
|
+
if (self.processor_status == "working") && (current_time >= self.restart_time)
|
112
|
+
runtime = current_time - self.start_time
|
113
|
+
self.app.logger.queue.info "Stopping after #{runtime} seconds of work"
|
114
|
+
set_status "stopping"
|
115
|
+
|
116
|
+
# On finishing wait for jobs to complete and then set status
|
117
|
+
# to idle
|
118
|
+
elsif self.processor_status == "finishing"
|
119
|
+
if self.job_count == 0
|
120
|
+
self.app.logger.queue.info "Setting processor status to idle"
|
121
|
+
set_status "idle"
|
122
|
+
end
|
123
|
+
|
124
|
+
# On stopping wait for jobs to complete and then set status
|
125
|
+
# to stopped. Once stopped the polling will terminate.
|
126
|
+
elsif self.processor_status == "stopping"
|
127
|
+
if self.job_count == 0
|
128
|
+
self.app.logger.queue.info "Setting processor status to stopped"
|
129
|
+
set_status "stopped"
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
|
134
|
+
if self.processor_status == "stopped"
|
135
|
+
self.app.logger.queue.info "Exiting program, Service requested to stop"
|
136
|
+
throw :stop_polling
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
def process_message(msg)
|
142
|
+
if self.processor_status == "working"
|
143
|
+
# If the app is already processing the max number of jobs
|
144
|
+
# put the message back in the queue with a short wait time
|
145
|
+
if self.job_count >= self.max_jobs
|
146
|
+
self.poller.change_message_visibility_timeout(msg, self.visibility_timeout)
|
147
|
+
throw :skip_delete
|
148
|
+
end
|
149
|
+
|
150
|
+
# Find out how many attempts there has been already for
|
151
|
+
# the message.
|
152
|
+
msg_attrs = msg.message_attributes.dup
|
153
|
+
attempt_number = msg_attrs.include?("attempts") ? msg_attrs["attempts"]["string_value"].to_i + 1 : 1
|
154
|
+
will_fail_permanently = attempt_number > self.attempt_threshold
|
155
|
+
|
156
|
+
# Run the job and increase the job count
|
157
|
+
# Once successful the job count is decreased by one
|
158
|
+
# and the message is deleted.
|
159
|
+
# If an error occured the job count is decreased by
|
160
|
+
# one and the error is logged locally and with sentry
|
161
|
+
self.job_count += 1
|
162
|
+
message = JSON.parse(msg.body)
|
163
|
+
process_message!(message, msg, will_fail_permanently).then {
|
164
|
+
self.job_count -= 1
|
165
|
+
|
166
|
+
}.rescue { |ex|
|
167
|
+
if will_fail_permanently
|
168
|
+
self.app.logger.queue.error "Message failed #{attempt_number} times, Reporting and failing permanently. \n#{ex.to_s} \n#{ex.backtrace.join("\n")}"
|
169
|
+
Raven.capture_exception(ex, {
|
170
|
+
:level => "fatal",
|
171
|
+
:extra => {
|
172
|
+
"queue_attempts" => attempt_number,
|
173
|
+
"queue_message_body" => msg.body
|
174
|
+
}
|
175
|
+
})
|
176
|
+
|
177
|
+
else
|
178
|
+
self.app.logger.queue.error "Message failed #{attempt_number} times, Adding back to queue."
|
179
|
+
|
180
|
+
if self.app.config.is_dev?
|
181
|
+
puts ex.inspect
|
182
|
+
puts ex.backtrace
|
183
|
+
end
|
184
|
+
|
185
|
+
replaced_message = {
|
186
|
+
:queue_url => self.poller.queue_url,
|
187
|
+
:message_body => msg.body,
|
188
|
+
:delay_seconds => self.visibility_timeout,
|
189
|
+
:message_attributes => msg_attrs.merge({
|
190
|
+
"attempts" => {
|
191
|
+
:string_value => attempt_number.to_s,
|
192
|
+
:data_type => "Number"
|
193
|
+
}
|
194
|
+
})
|
195
|
+
}
|
196
|
+
|
197
|
+
self.poller.client.send_message replaced_message
|
198
|
+
end
|
199
|
+
|
200
|
+
if self.app.config.is_dev?
|
201
|
+
self.app.logger.queue.error "Processor Error:"
|
202
|
+
self.app.logger.queue.error ex.message
|
203
|
+
self.app.logger.queue.error ex.backtrace
|
204
|
+
end
|
205
|
+
|
206
|
+
self.job_count -= 1
|
207
|
+
}.execute
|
208
|
+
else
|
209
|
+
self.poller.change_message_visibility_timeout(msg, self.visibility_timeout * 2)
|
210
|
+
throw :skip_delete
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
|
215
|
+
private
|
216
|
+
def reset_time
|
217
|
+
self.start_time = Time.now.utc.to_i
|
218
|
+
self.restart_time = self.start_time + (3600) # One hour
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|