eventboss 1.0.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/.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
|