eventboss 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +57 -0
- data/Guardfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +6 -0
- data/bin/eventboss +47 -0
- data/eventboss.gemspec +31 -0
- data/lib/eventboss.rb +79 -0
- data/lib/eventboss/configuration.rb +104 -0
- data/lib/eventboss/error_handlers/airbrake.rb +13 -0
- data/lib/eventboss/error_handlers/logger.rb +12 -0
- data/lib/eventboss/extensions.rb +2 -0
- data/lib/eventboss/fetcher.rb +33 -0
- data/lib/eventboss/instrumentation.rb +26 -0
- data/lib/eventboss/launcher.rb +96 -0
- data/lib/eventboss/listener.rb +28 -0
- data/lib/eventboss/logger.rb +34 -0
- data/lib/eventboss/logging.rb +8 -0
- data/lib/eventboss/long_poller.rb +75 -0
- data/lib/eventboss/manager.rb +116 -0
- data/lib/eventboss/polling/basic.rb +68 -0
- data/lib/eventboss/polling/timed_round_robin.rb +42 -0
- data/lib/eventboss/publisher.rb +32 -0
- data/lib/eventboss/queue.rb +42 -0
- data/lib/eventboss/queue_listener.rb +11 -0
- data/lib/eventboss/railtie.rb +5 -0
- data/lib/eventboss/runner.rb +60 -0
- data/lib/eventboss/safe_thread.rb +23 -0
- data/lib/eventboss/scripts.rb +1 -0
- data/lib/eventboss/sender.rb +25 -0
- data/lib/eventboss/sns_client.rb +55 -0
- data/lib/eventboss/unit_of_work.rb +33 -0
- data/lib/eventboss/version.rb +3 -0
- data/lib/eventboss/worker.rb +55 -0
- data/lib/tasks/eventboss.rake +47 -0
- metadata +192 -0
@@ -0,0 +1,13 @@
|
|
1
|
+
module Eventboss
|
2
|
+
module ErrorHandlers
|
3
|
+
class Airbrake
|
4
|
+
def call(exception, context = {})
|
5
|
+
::Airbrake.notify(exception) do |notice|
|
6
|
+
notice[:context][:component] = 'eventboss'
|
7
|
+
notice[:context][:action] = context[:processor].class.to_s if context[:processor]
|
8
|
+
notice[:context].merge!(context)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Eventboss
|
2
|
+
module ErrorHandlers
|
3
|
+
class Logger
|
4
|
+
def call(exception, context = {})
|
5
|
+
notice = {}.merge!(context)
|
6
|
+
notice[:jid] = notice[:processor].jid if notice[:processor]
|
7
|
+
notice[:processor] = notice[:processor].class.to_s if notice[:processor]
|
8
|
+
Eventboss::Logger.error("Failure processing request #{exception.message}", notice)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Eventboss
|
2
|
+
class Fetcher
|
3
|
+
FETCH_LIMIT = 10 # maximum possible for SQS
|
4
|
+
|
5
|
+
attr_reader :client
|
6
|
+
|
7
|
+
def initialize(configuration)
|
8
|
+
@client = configuration.sqs_client
|
9
|
+
end
|
10
|
+
|
11
|
+
def fetch(queue, limit)
|
12
|
+
@client.receive_message(queue_url: queue.url, max_number_of_messages: max_no_of_messages(limit)).messages
|
13
|
+
end
|
14
|
+
|
15
|
+
def delete(queue, message)
|
16
|
+
@client.delete_message(queue_url: queue.url, receipt_handle: message.receipt_handle)
|
17
|
+
end
|
18
|
+
|
19
|
+
def change_message_visibility(queue, message, visibility_timeout)
|
20
|
+
@client.change_message_visibility(
|
21
|
+
queue_url: queue.url,
|
22
|
+
receipt_handle: message.receipt_handle,
|
23
|
+
visibility_timeout: visibility_timeout
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def max_no_of_messages(limit)
|
30
|
+
[limit, FETCH_LIMIT].min
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Eventboss
|
2
|
+
# :nodoc:
|
3
|
+
module Instrumentation
|
4
|
+
def self.add(queue_listeners)
|
5
|
+
return unless defined?(::NewRelic::Agent::Instrumentation::ControllerInstrumentation)
|
6
|
+
Eventboss::Instrumentation::NewRelic.install(queue_listeners)
|
7
|
+
end
|
8
|
+
|
9
|
+
# :nodoc:
|
10
|
+
module NewRelic
|
11
|
+
def self.install(queue_listeners)
|
12
|
+
Eventboss::Logger.logger.info('Loaded NewRelic instrumentation')
|
13
|
+
queue_listeners.each_value do |listener_class|
|
14
|
+
listener_class.include(::NewRelic::Agent::Instrumentation::ControllerInstrumentation)
|
15
|
+
listener_class.add_transaction_tracer(:receive, category: 'OtherTransaction/EventbossJob')
|
16
|
+
end
|
17
|
+
|
18
|
+
Eventboss::Sender.include(::NewRelic::Agent::MethodTracer)
|
19
|
+
Eventboss::Sender.add_method_tracer(:send_batch, 'Eventboss/sender_send_batch')
|
20
|
+
|
21
|
+
Eventboss::Publisher.include(::NewRelic::Agent::MethodTracer)
|
22
|
+
Eventboss::Publisher.add_method_tracer(:publish, 'Eventboss/publisher_publish')
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
module Eventboss
|
2
|
+
# Launcher manages lifecycle of queues and pollers threads
|
3
|
+
class Launcher
|
4
|
+
include Logging
|
5
|
+
|
6
|
+
DEFAULT_SHUTDOWN_ATTEMPTS = 5
|
7
|
+
DEFAULT_SHUTDOWN_DELAY = 5
|
8
|
+
|
9
|
+
def initialize(queues, client, options = {})
|
10
|
+
@options = options
|
11
|
+
@queues = queues
|
12
|
+
@client = client
|
13
|
+
|
14
|
+
@lock = Mutex.new
|
15
|
+
@bus = SizedQueue.new(@queues.size * 10)
|
16
|
+
|
17
|
+
@pollers = Set.new
|
18
|
+
@queues.each { |q, listener| @pollers << new_poller(q, listener) }
|
19
|
+
|
20
|
+
@workers = Set.new
|
21
|
+
worker_count.times { |id| @workers << new_worker(id) }
|
22
|
+
end
|
23
|
+
|
24
|
+
def start
|
25
|
+
logger.info("Starting #{@workers.size} workers, #{@pollers.size} pollers", 'launcher')
|
26
|
+
@pollers.each(&:start)
|
27
|
+
@workers.each(&:start)
|
28
|
+
end
|
29
|
+
|
30
|
+
def stop
|
31
|
+
logger.info('Gracefully shutdown', 'launcher')
|
32
|
+
|
33
|
+
@bus.clear
|
34
|
+
@pollers.each(&:terminate)
|
35
|
+
@workers.each(&:terminate)
|
36
|
+
|
37
|
+
wait_for_shutdown
|
38
|
+
hard_shutdown
|
39
|
+
end
|
40
|
+
|
41
|
+
def hard_shutdown
|
42
|
+
return if @pollers.empty? && @workers.empty?
|
43
|
+
|
44
|
+
logger.info("Killing remaining #{@pollers.size} pollers, #{@workers.size} workers", 'launcher')
|
45
|
+
@pollers.each(&:kill)
|
46
|
+
@workers.each(&:kill)
|
47
|
+
end
|
48
|
+
|
49
|
+
def worker_stopped(worker, restart: false)
|
50
|
+
@lock.synchronize do
|
51
|
+
@workers.delete(worker)
|
52
|
+
@workers << new_worker(worker.id).tap(&:start) if restart
|
53
|
+
end
|
54
|
+
logger.debug("Worker #{worker.id} stopped, restart: #{restart}", 'launcher')
|
55
|
+
end
|
56
|
+
|
57
|
+
def poller_stopped(poller, restart: false)
|
58
|
+
@lock.synchronize do
|
59
|
+
@pollers.delete(poller)
|
60
|
+
@pollers << new_poller(poller.queue, poller.listener).tap(&:start) if restart
|
61
|
+
end
|
62
|
+
logger.debug("Poller #{poller.id} stopped, restart: #{restart}", 'launcher')
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def worker_count
|
68
|
+
@options.fetch(:worker_count, [2, Concurrent.processor_count].max)
|
69
|
+
end
|
70
|
+
|
71
|
+
def new_worker(id)
|
72
|
+
Worker.new(self, id, @client, @bus)
|
73
|
+
end
|
74
|
+
|
75
|
+
def new_poller(queue, listener)
|
76
|
+
LongPoller.new(self, @bus, @client, queue, listener)
|
77
|
+
end
|
78
|
+
|
79
|
+
def wait_for_shutdown
|
80
|
+
attempts = 0
|
81
|
+
while @pollers.any? || @workers.any?
|
82
|
+
break if (attempts += 1) > shutdown_attempts
|
83
|
+
sleep shutdown_delay
|
84
|
+
logger.info("Waiting for #{@pollers.size} pollers, #{@workers.size} workers", 'launcher')
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def shutdown_attempts
|
89
|
+
Integer(@options[:shutdown_attempts] || DEFAULT_SHUTDOWN_ATTEMPTS)
|
90
|
+
end
|
91
|
+
|
92
|
+
def shutdown_delay
|
93
|
+
Integer(@options[:shutdown_delay] || DEFAULT_SHUTDOWN_DELAY)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Eventboss
|
2
|
+
module Listener
|
3
|
+
ACTIVE_LISTENERS = {}
|
4
|
+
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
def jid
|
10
|
+
@jid ||= SecureRandom.uuid
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :postponed_by
|
14
|
+
|
15
|
+
def postpone_by(time_in_secs)
|
16
|
+
@postponed_by = time_in_secs.to_i
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
def eventboss_options(opts)
|
21
|
+
source_app = opts[:source_app] ? "#{opts[:source_app]}-" : ""
|
22
|
+
event_name = opts[:event_name]
|
23
|
+
|
24
|
+
ACTIVE_LISTENERS["#{source_app}#{event_name}"] = self
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Eventboss
|
2
|
+
class Logger
|
3
|
+
class << self
|
4
|
+
def logger
|
5
|
+
Thread.current[:ah_eventboss_logger] ||= ::Logger.new(
|
6
|
+
STDOUT,
|
7
|
+
level: Eventboss.configuration.log_level
|
8
|
+
)
|
9
|
+
end
|
10
|
+
|
11
|
+
def info(msg, tag = nil)
|
12
|
+
return unless logger
|
13
|
+
logger.info(tagged(msg, tag))
|
14
|
+
end
|
15
|
+
|
16
|
+
def debug(msg, tag = nil)
|
17
|
+
return unless logger
|
18
|
+
logger.debug(tagged(msg, tag))
|
19
|
+
end
|
20
|
+
|
21
|
+
def error(msg, tag = nil)
|
22
|
+
return unless logger
|
23
|
+
logger.error(tagged(msg, tag))
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def tagged(msg, tag)
|
29
|
+
return msg if tag.nil?
|
30
|
+
msg.prepend("[#{tag}] ")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Eventboss
|
2
|
+
# LongPoller fetches messages from SQS using Long Polling
|
3
|
+
# http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-long-polling.html
|
4
|
+
# It starts one thread per queue (handled by Launcher)
|
5
|
+
class LongPoller
|
6
|
+
include Logging
|
7
|
+
include SafeThread
|
8
|
+
|
9
|
+
TIME_WAIT = 10
|
10
|
+
|
11
|
+
attr_reader :id, :queue, :listener
|
12
|
+
|
13
|
+
def initialize(launcher, bus, client, queue, listener)
|
14
|
+
@id = "poller-#{queue.name}"
|
15
|
+
@launcher = launcher
|
16
|
+
@bus = bus
|
17
|
+
@client = client
|
18
|
+
@queue = queue
|
19
|
+
@listener = listener
|
20
|
+
@thread = nil
|
21
|
+
@stop = false
|
22
|
+
end
|
23
|
+
|
24
|
+
def start
|
25
|
+
@thread = safe_thread(id, &method(:run))
|
26
|
+
end
|
27
|
+
|
28
|
+
def terminate(wait = false)
|
29
|
+
@stop = true
|
30
|
+
return unless @thread
|
31
|
+
@thread.value if wait
|
32
|
+
end
|
33
|
+
|
34
|
+
def kill(wait = false)
|
35
|
+
@stop = true
|
36
|
+
return unless @thread
|
37
|
+
@thread.value if wait
|
38
|
+
|
39
|
+
# Force shutdown of poller, in case the loop is stuck
|
40
|
+
@thread.raise Eventboss::Shutdown
|
41
|
+
@thread.value if wait
|
42
|
+
end
|
43
|
+
|
44
|
+
def fetch_and_dispatch
|
45
|
+
fetch_messages.each do |message|
|
46
|
+
logger.debug("enqueueing message #{message.message_id}", id)
|
47
|
+
@bus << UnitOfWork.new(queue, listener, message)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def run
|
52
|
+
fetch_and_dispatch until @stop
|
53
|
+
@launcher.poller_stopped(self)
|
54
|
+
rescue Eventboss::Shutdown
|
55
|
+
@launcher.poller_stopped(self)
|
56
|
+
rescue StandardError => exception
|
57
|
+
handle_exception(exception, poller_id: id)
|
58
|
+
# Give a chance for temporary AWS errors to be resolved
|
59
|
+
# Sleep guarantees against repeating fast failure errors
|
60
|
+
sleep TIME_WAIT
|
61
|
+
@launcher.poller_stopped(self, restart: @stop == false)
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def fetch_messages
|
67
|
+
logger.debug('fetching messages', id)
|
68
|
+
@client.receive_message(
|
69
|
+
queue_url: queue.url,
|
70
|
+
max_number_of_messages: 10,
|
71
|
+
wait_time_seconds: TIME_WAIT
|
72
|
+
).messages
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module Eventboss
|
2
|
+
class Manager
|
3
|
+
MIN_DISPATCH_INTERVAL = 0.1
|
4
|
+
|
5
|
+
def initialize(fetcher, polling_strategy, executor, queue_listeners, concurrency, error_handlers)
|
6
|
+
@fetcher = fetcher
|
7
|
+
@polling_strategy = polling_strategy
|
8
|
+
@max_processors = concurrency
|
9
|
+
@busy_processors = Concurrent::AtomicFixnum.new(0)
|
10
|
+
@executor = executor
|
11
|
+
@queue_listeners = queue_listeners
|
12
|
+
@error_handlers = Array(error_handlers)
|
13
|
+
end
|
14
|
+
|
15
|
+
def start
|
16
|
+
Eventboss::Logger.debug('Starting dispatch loop...')
|
17
|
+
|
18
|
+
dispatch_loop
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def running?
|
24
|
+
@executor.running?
|
25
|
+
end
|
26
|
+
|
27
|
+
def dispatch_loop
|
28
|
+
return unless running?
|
29
|
+
|
30
|
+
Eventboss::Logger.debug('Posting task to executor')
|
31
|
+
|
32
|
+
@executor.post { dispatch }
|
33
|
+
end
|
34
|
+
|
35
|
+
def dispatch
|
36
|
+
return unless running?
|
37
|
+
|
38
|
+
if ready <= 0 || (queue = @polling_strategy.next_queue).nil?
|
39
|
+
return sleep(MIN_DISPATCH_INTERVAL)
|
40
|
+
end
|
41
|
+
dispatch_single_messages(queue)
|
42
|
+
rescue => ex
|
43
|
+
handle_dispatch_error(ex)
|
44
|
+
ensure
|
45
|
+
Eventboss::Logger.debug('Ensuring dispatch loop')
|
46
|
+
dispatch_loop
|
47
|
+
end
|
48
|
+
|
49
|
+
def busy
|
50
|
+
@busy_processors.value
|
51
|
+
end
|
52
|
+
|
53
|
+
def ready
|
54
|
+
@max_processors - busy
|
55
|
+
end
|
56
|
+
|
57
|
+
def processor_done(processor)
|
58
|
+
Eventboss::Logger.info("Success", processor.jid)
|
59
|
+
@busy_processors.decrement
|
60
|
+
end
|
61
|
+
|
62
|
+
def processor_error(processor, exception)
|
63
|
+
@error_handlers.each { |handler| handler.call(exception, processor) }
|
64
|
+
@busy_processors.decrement
|
65
|
+
end
|
66
|
+
|
67
|
+
def assign(queue, sqs_msg)
|
68
|
+
return unless running?
|
69
|
+
|
70
|
+
@busy_processors.increment
|
71
|
+
processor = @queue_listeners[queue].new
|
72
|
+
|
73
|
+
Concurrent::Promise.execute(executor: @executor) do
|
74
|
+
body = JSON.parse(sqs_msg.body) rescue sqs_msg.body
|
75
|
+
Eventboss::Logger.info("Started", processor.jid)
|
76
|
+
processor.receive(body)
|
77
|
+
end.then do
|
78
|
+
cleanup(processor)
|
79
|
+
postpone_if_needed(queue, sqs_msg, processor) || delete_from_queue(queue, sqs_msg)
|
80
|
+
processor_done(processor)
|
81
|
+
end.rescue do |e|
|
82
|
+
cleanup(processor)
|
83
|
+
postpone_if_needed(queue, sqs_msg, processor)
|
84
|
+
processor_error(processor, e)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def cleanup(_processor)
|
89
|
+
if defined?(ActiveRecord)
|
90
|
+
::ActiveRecord::Base.clear_active_connections!
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def delete_from_queue(queue, sqs_msg)
|
95
|
+
@fetcher.delete(queue, sqs_msg)
|
96
|
+
end
|
97
|
+
|
98
|
+
def postpone_if_needed(queue, sqs_msg, processor)
|
99
|
+
return false unless processor.postponed_by
|
100
|
+
@fetcher.change_message_visibility(queue, sqs_msg, processor.postponed_by)
|
101
|
+
rescue => error
|
102
|
+
Eventboss::Logger.info("Could not postpone message #{error.message}", processor.jid)
|
103
|
+
end
|
104
|
+
|
105
|
+
def dispatch_single_messages(queue)
|
106
|
+
messages = @fetcher.fetch(queue, ready)
|
107
|
+
@polling_strategy.messages_found(queue, messages.size)
|
108
|
+
messages.each { |message| assign(queue, message) }
|
109
|
+
end
|
110
|
+
|
111
|
+
def handle_dispatch_error(ex)
|
112
|
+
Eventboss::Logger.error("Error dispatching #{ex.message}")
|
113
|
+
Process.kill('USR1', Process.pid)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|