isimud 0.7.0 → 1.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rspec +1 -2
- data/.ruby-version +1 -1
- data/.yardoc/checksums +10 -10
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +63 -73
- data/LICENSE.txt +19 -0
- data/README.md +51 -3
- data/Rakefile +5 -0
- data/doc/Isimud/BunnyClient.html +882 -179
- data/doc/Isimud/Client.html +236 -18
- data/doc/Isimud/Event.html +211 -95
- data/doc/Isimud/EventListener.html +325 -307
- data/doc/Isimud/EventObserver/ClassMethods.html +14 -14
- data/doc/Isimud/EventObserver.html +418 -36
- data/doc/Isimud/Generators/ConfigGenerator.html +2 -2
- data/doc/Isimud/Generators/InitializerGenerator.html +2 -2
- data/doc/Isimud/Generators.html +2 -2
- data/doc/Isimud/Logging.html +3 -3
- data/doc/Isimud/ModelWatcher/ClassMethods.html +3 -3
- data/doc/Isimud/ModelWatcher.html +2 -2
- data/doc/Isimud/Railtie.html +2 -2
- data/doc/Isimud/TestClient/Queue.html +374 -71
- data/doc/Isimud/TestClient.html +169 -161
- data/doc/Isimud.html +80 -76
- data/doc/_index.html +5 -2
- data/doc/file.LICENSE.html +73 -0
- data/doc/file.README.html +131 -7
- data/doc/file_list.html +3 -0
- data/doc/index.html +131 -7
- data/doc/method_list.html +183 -105
- data/doc/top-level-namespace.html +2 -2
- data/isimud.gemspec +18 -16
- data/lib/isimud/bunny_client.rb +85 -32
- data/lib/isimud/client.rb +23 -7
- data/lib/isimud/event.rb +11 -14
- data/lib/isimud/event_listener.rb +123 -65
- data/lib/isimud/event_observer.rb +70 -10
- data/lib/isimud/model_watcher.rb +1 -1
- data/lib/isimud/test_client.rb +54 -26
- data/lib/isimud/version.rb +1 -1
- data/lib/isimud.rb +1 -1
- data/spec/internal/app/models/company.rb +8 -10
- data/spec/internal/app/models/user.rb +11 -1
- data/spec/internal/db/schema.rb +5 -0
- data/spec/isimud/bunny_client_spec.rb +21 -9
- data/spec/isimud/client_spec.rb +40 -0
- data/spec/isimud/event_listener_spec.rb +50 -22
- data/spec/isimud/event_observer_spec.rb +107 -16
- data/spec/isimud/model_watcher_spec.rb +18 -23
- data/spec/isimud/test_client_spec.rb +43 -8
- data/spec/isimud_spec.rb +2 -2
- data/spec/spec_helper.rb +2 -0
- metadata +19 -35
- checksums.yaml.gz.sig +0 -0
- data/certs/gfeil.pem +0 -21
- data/release +0 -31
- data.tar.gz.sig +0 -0
- metadata.gz.sig +0 -2
data/isimud.gemspec
CHANGED
@@ -4,28 +4,30 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
4
|
require 'isimud/version'
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
|
-
spec.name
|
8
|
-
spec.version
|
9
|
-
spec.authors
|
10
|
-
spec.email
|
11
|
-
spec.summary
|
12
|
-
spec.description
|
13
|
-
Isimud is an AMQP message publishing and consumption gem intended for Rails applications.
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
7
|
+
spec.name = 'isimud'
|
8
|
+
spec.version = Isimud::VERSION
|
9
|
+
spec.authors = ['George Feil', 'Brian Jenkins']
|
10
|
+
spec.email = %w{george.feil@keas.com bonkydog@bonkydog.com}
|
11
|
+
spec.summary = %q{AMQP Messaging and Event Processing}
|
12
|
+
spec.description = <<-EOT
|
13
|
+
Isimud is an AMQP message publishing and consumption gem that is intended for managing asynchronous event queues in Rails applications. It consists of the following components:
|
14
|
+
|
15
|
+
* A [Bunny](http://rubybunny.info) based client interface for publishing and receiving messages using AMQP.
|
16
|
+
* A test client which mocks most client operations and allows for synchronous delivery and processing of messages for unit tests.
|
17
|
+
* A Model Watcher mixin for ActiveRecord that automatically sends messages whenever an ActiveRecord instance is created, modified, or destroyed.
|
18
|
+
* An Event Observer mixin for registering ActiveRecord models and instances with the EventListener for receiving messages.
|
19
|
+
* An Event Listener daemon process which manages queues and dispatches messages for Event Observers.
|
20
|
+
EOT
|
21
|
+
spec.homepage = 'https://github.com/KeasInc/isimud'
|
22
|
+
spec.license = 'MITNFA'
|
21
23
|
|
22
24
|
spec.files = `git ls-files -z`.split("\x0")
|
23
25
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
24
26
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
25
27
|
spec.require_paths = ['lib']
|
26
28
|
|
27
|
-
spec.add_dependency 'activerecord', '>=
|
28
|
-
spec.add_dependency 'activesupport', '>=
|
29
|
+
spec.add_dependency 'activerecord', '>= 4.1.4'
|
30
|
+
spec.add_dependency 'activesupport', '>= 4.1.4'
|
29
31
|
spec.add_dependency 'bunny', '>= 1.6.0'
|
30
32
|
spec.add_dependency 'chronic_duration', '>= 0.10.6'
|
31
33
|
end
|
data/lib/isimud/bunny_client.rb
CHANGED
@@ -2,64 +2,96 @@ require 'bunny'
|
|
2
2
|
require 'logger'
|
3
3
|
|
4
4
|
module Isimud
|
5
|
+
|
6
|
+
# Interface for Bunny RabbitMQ client
|
7
|
+
# @see http://rubybunny.info
|
5
8
|
class BunnyClient < Isimud::Client
|
6
9
|
DEFAULT_URL = 'amqp://guest:guest@localhost'
|
7
10
|
|
8
11
|
attr_reader :url
|
9
12
|
|
13
|
+
# Initialize a new BunnyClient instance. Note that a connection is not established until any other method is called
|
14
|
+
#
|
15
|
+
# @param [String, Hash] _url Server URL or options hash
|
16
|
+
# @param [Hash] _bunny_options optional Bunny connection options
|
17
|
+
# @see Bunny.new for connection options
|
10
18
|
def initialize(_url = nil, _bunny_options = {})
|
11
19
|
log "Isimud::BunnyClient.initialize: options = #{_bunny_options.inspect}"
|
12
20
|
@url = _url || DEFAULT_URL
|
13
21
|
@bunny_options = _bunny_options
|
14
22
|
end
|
15
23
|
|
24
|
+
# Convenience method that finds or creates a named queue, binds to an exchange, and subscribes to messages.
|
25
|
+
# If a block is provided, it will be called by the consumer each time a message is received.
|
26
|
+
#
|
27
|
+
# @param [String] queue_name name of the queue
|
28
|
+
# @param [String] exchange_name name of the AMQP exchange. Note that existing exchanges must be declared as Topic
|
29
|
+
# exchanges; otherwise, an error will occur
|
30
|
+
# @param [Array<String>] routing_keys list of routing keys to be bound to the queue for the specified exchange.
|
31
|
+
# @yieldparam [String] payload message text
|
32
|
+
# @return [Bunny::Consumer] Bunny consumer interface
|
16
33
|
def bind(queue_name, exchange_name, *routing_keys, &block)
|
17
|
-
create_queue(queue_name, exchange_name,
|
18
|
-
|
19
|
-
|
20
|
-
|
34
|
+
queue = create_queue(queue_name, exchange_name,
|
35
|
+
queue_options: {durable: true},
|
36
|
+
routing_keys: routing_keys)
|
37
|
+
subscribe(queue, &block) if block_given?
|
38
|
+
end
|
39
|
+
|
40
|
+
# Find or create a named queue and bind it to the specified exchange
|
41
|
+
#
|
42
|
+
# @param [String] queue_name name of the queue
|
43
|
+
# @param [String] exchange_name name of the AMQP exchange. Note that pre-existing exchanges must be declared as Topic
|
44
|
+
# exchanges; otherwise, an error will occur
|
45
|
+
# @param [Hash] options queue declaration options
|
46
|
+
# @option options [Boolean] :queue_options ({durable: true}) queue declaration options -- @see Bunny::Channel#queue
|
47
|
+
# @option options [Array<String>] :routing_keys ([]) routing keys to be bound to the queue. Use "*" to match any 1 word
|
48
|
+
# in a route segment. Use "#" to match 0 or more words in a segment.
|
49
|
+
# @return [Bunny::Queue] Bunny queue
|
50
|
+
def create_queue(queue_name, exchange_name, options = {})
|
51
|
+
queue_options = options[:queue_options] || {durable: true}
|
52
|
+
routing_keys = options[:routing_keys] || []
|
53
|
+
log "Isimud::BunnyClient: create_queue #{queue_name}: queue_options=#{queue_options.inspect}"
|
54
|
+
queue = find_queue(queue_name, queue_options)
|
55
|
+
bind_routing_keys(queue, exchange_name, routing_keys) if routing_keys.any?
|
56
|
+
queue
|
21
57
|
end
|
22
58
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
59
|
+
# Subscribe to messages on the Bunny queue. The provided block will be called each time a message is received.
|
60
|
+
# The message will be acknowledged and deleted from the queue unless an exception is raised from the block.
|
61
|
+
# In the case that an exception is caught, the message is rejected, and any declared exception handlers will
|
62
|
+
# be called.
|
63
|
+
#
|
64
|
+
# @param [Bunny::Queue] queue Bunny queue
|
65
|
+
# @param [Hash] options {manual_ack: true} subscription options -- @see Bunny::Queue#subscribe
|
66
|
+
# @yieldparam [String] payload message text
|
67
|
+
def subscribe(queue, options = {manual_ack: true}, &block)
|
28
68
|
current_channel = channel
|
29
|
-
queue
|
30
|
-
bind_routing_keys(queue, exchange_name, routing_keys)
|
31
|
-
queue.subscribe(subscribe_options) do |delivery_info, properties, payload|
|
69
|
+
queue.subscribe(options) do |delivery_info, properties, payload|
|
32
70
|
begin
|
33
|
-
log "Isimud: queue #{
|
34
|
-
Thread.current['isimud_queue_name'] =
|
71
|
+
log "Isimud: queue #{queue.name} received #{delivery_info.delivery_tag} routing_key: #{delivery_info.routing_key}"
|
72
|
+
Thread.current['isimud_queue_name'] = queue.name
|
35
73
|
Thread.current['isimud_delivery_info'] = delivery_info
|
36
74
|
Thread.current['isimud_properties'] = properties
|
37
75
|
block.call(payload)
|
38
|
-
log "Isimud: queue #{
|
76
|
+
log "Isimud: queue #{queue.name} finished with #{delivery_info.delivery_tag}, acknowledging"
|
39
77
|
current_channel.ack(delivery_info.delivery_tag)
|
40
78
|
rescue => e
|
41
|
-
log("Isimud: queue #{
|
79
|
+
log("Isimud: queue #{queue.name} error processing #{delivery_info.delivery_tag} payload #{payload.inspect}: #{e.class.name} #{e.message}\n #{e.backtrace.join("\n ")}", :warn)
|
42
80
|
current_channel.reject(delivery_info.delivery_tag, Isimud.retry_failures)
|
43
|
-
|
81
|
+
run_exception_handlers(e)
|
44
82
|
end
|
45
83
|
end
|
46
|
-
queue
|
47
|
-
end
|
48
|
-
|
49
|
-
# replace all bindings on a queue
|
50
|
-
def rebind(queue_name, exchange_name, routing_keys)
|
51
|
-
log "Isimud: rebinding queue #{queue_name} exchange #{exchange_name} routing_keys #{routing_keys.inspect}"
|
52
|
-
queue = find_queue(queue_name)
|
53
|
-
queue.unbind(exchange_name)
|
54
|
-
bind_routing_keys(queue, exchange_name, routing_keys)
|
55
|
-
rescue => e
|
56
|
-
log "Isimud: error rebinding #{queue_name} from #{exchange_name}: #{e.message}", :error
|
57
84
|
end
|
58
85
|
|
86
|
+
# Permanently delete the queue from the AMQP server. Any messages present in the queue will be discarded.
|
87
|
+
# @param [String] queue_name queue name
|
88
|
+
# @return [AMQ::Protocol::Queue::DeleteOk] RabbitMQ response
|
59
89
|
def delete_queue(queue_name)
|
60
90
|
channel.queue_delete(queue_name)
|
61
91
|
end
|
62
92
|
|
93
|
+
# Establish a connection to the AMQP server, or return the current connection if one already exists
|
94
|
+
# @return [Bunny::Session]
|
63
95
|
def connection
|
64
96
|
@connection ||= ::Bunny.new(url, @bunny_options).tap(&:start)
|
65
97
|
end
|
@@ -68,6 +100,10 @@ module Isimud
|
|
68
100
|
|
69
101
|
CHANNEL_KEY = :'isimud.bunny_client.channel'
|
70
102
|
|
103
|
+
# Open a new, thread-specific AMQP connection channel, or return the current channel for this thread if it exists
|
104
|
+
# and is currently open. New channels are created with publisher confirms enabled. Messages will be prefetched
|
105
|
+
# according to Isimud.prefetch_count when declared.
|
106
|
+
# @return [Bunny::Channel] channel instance.
|
71
107
|
def channel
|
72
108
|
if (channel = Thread.current[CHANNEL_KEY]).try(:open?)
|
73
109
|
channel
|
@@ -79,36 +115,53 @@ module Isimud
|
|
79
115
|
end
|
80
116
|
end
|
81
117
|
|
118
|
+
# Reset this client by closing all channels for the connection.
|
82
119
|
def reset
|
83
120
|
connection.close_all_channels
|
84
121
|
end
|
85
122
|
|
123
|
+
# Determine if a Bunny connection is currently established to the AMQP server.
|
124
|
+
# @return [Boolean,nil] true if a connection was established and is active or starting, false if a connection exists
|
125
|
+
# but is closed or closing, or nil if no connection has been established.
|
86
126
|
def connected?
|
87
127
|
@connection && @connection.open?
|
88
128
|
end
|
89
129
|
|
130
|
+
# Close the AMQP connection and clear it from the instance.
|
131
|
+
# @return nil
|
90
132
|
def close
|
91
133
|
connection.close
|
92
134
|
ensure
|
93
135
|
@connection = nil
|
94
136
|
end
|
95
137
|
|
96
|
-
|
97
|
-
|
138
|
+
# Publish a message to the specified exchange, which is declared as a durable, topic exchange. Note that message
|
139
|
+
# is always persisted.
|
140
|
+
# @param [String] exchange AMQP exchange name
|
141
|
+
# @param [String] routing_key message routing key. This should always be in the form of words separated by dots
|
142
|
+
# e.g. "user.goal.complete"
|
143
|
+
# @see http://rubybunny.info/articles/exchanges.html
|
144
|
+
def publish(exchange, routing_key, payload)
|
145
|
+
channel.topic(exchange, durable: true).publish(payload, routing_key: routing_key, persistent: true)
|
98
146
|
end
|
99
147
|
|
148
|
+
# Close and reopen the AMQP connection
|
149
|
+
# @return [Bunny::Session]
|
100
150
|
def reconnect
|
101
151
|
close
|
102
152
|
connect
|
103
153
|
end
|
104
154
|
|
105
|
-
|
106
|
-
|
155
|
+
# Look up a queue by name, or create it if it does not already exist.
|
107
156
|
def find_queue(queue_name, options = {durable: true})
|
108
157
|
channel.queue(queue_name, options)
|
109
158
|
end
|
110
159
|
|
160
|
+
private
|
161
|
+
|
111
162
|
def bind_routing_keys(queue, exchange_name, routing_keys)
|
163
|
+
log "Isimud::BunnyClient: bind queue #{queue.name} exchange #{exchange_name} routing_keys: #{routing_keys.join(',')}"
|
164
|
+
channel.exchange(exchange_name, type: :topic, durable: true)
|
112
165
|
routing_keys.each { |key| queue.bind(exchange_name, routing_key: key, nowait: false) }
|
113
166
|
end
|
114
167
|
|
data/lib/isimud/client.rb
CHANGED
@@ -3,8 +3,6 @@ module Isimud
|
|
3
3
|
class Client
|
4
4
|
include Isimud::Logging
|
5
5
|
|
6
|
-
attr_reader :exception_handler
|
7
|
-
|
8
6
|
def initialize(server = nil, options = nil)
|
9
7
|
end
|
10
8
|
|
@@ -23,27 +21,45 @@ module Isimud
|
|
23
21
|
def connected?
|
24
22
|
end
|
25
23
|
|
26
|
-
def create_queue(queue_name, exchange_name, options = {}
|
24
|
+
def create_queue(queue_name, exchange_name, options = {})
|
27
25
|
end
|
28
26
|
|
29
27
|
def delete_queue(queue_name)
|
30
28
|
end
|
31
29
|
|
30
|
+
def find_queue(queue_name, options = {})
|
31
|
+
end
|
32
|
+
|
33
|
+
# Declare a proc to be run whenever an uncaught exception is raised within a message processing block.
|
34
|
+
# This is useful for logging or monitoring errors, for instance.
|
35
|
+
# @yieldparam [Exception] e exception raised
|
32
36
|
def on_exception(&block)
|
33
|
-
|
37
|
+
exception_handlers << block
|
34
38
|
end
|
35
39
|
|
36
|
-
|
40
|
+
# Call each of the exception handlers declared by #on_exception.
|
41
|
+
# @param [Exception] exception
|
42
|
+
def run_exception_handlers(exception)
|
43
|
+
exception_handlers.each{|handler| handler.call(exception)}
|
37
44
|
end
|
38
45
|
|
39
|
-
def
|
46
|
+
def publish(exchange, routing_key, payload)
|
40
47
|
end
|
41
48
|
|
42
|
-
def
|
49
|
+
def reconnect
|
43
50
|
end
|
44
51
|
|
45
52
|
def reset
|
46
53
|
end
|
54
|
+
|
55
|
+
def subscribe(queue, options = {}, &block)
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def exception_handlers
|
61
|
+
@exception_handlers ||= Array.new
|
62
|
+
end
|
47
63
|
end
|
48
64
|
end
|
49
65
|
|
data/lib/isimud/event.rb
CHANGED
@@ -1,15 +1,19 @@
|
|
1
1
|
require 'active_support'
|
2
2
|
|
3
3
|
module Isimud
|
4
|
+
# A structured message format useful for processing events.
|
5
|
+
# Note that each message has a routing key automatically constructed based on four properties:
|
6
|
+
# type.eventful_type.eventful_id.action
|
7
|
+
# Any blank or nil properties are omitted from the routing key.
|
8
|
+
# For convenience, you may construct an event using an eventful object, which sets the eventful_type and eventful_id
|
4
9
|
class Event
|
5
10
|
include Isimud::Logging
|
6
11
|
attr_accessor :type, :action, :user_id, :occurred_at, :eventful_type, :eventful_id, :attributes, :parameters
|
7
|
-
attr_reader :timestamp
|
8
12
|
attr_writer :exchange
|
9
13
|
|
10
14
|
DEFAULT_TYPE = :model
|
11
15
|
|
12
|
-
# Initialize a new Event
|
16
|
+
# Initialize a new Event.
|
13
17
|
# @overload new(user, eventful, attributes)
|
14
18
|
# @param[#id] user user associated by the event
|
15
19
|
# @param[ActiveRecord::Base] eventful object associated with event
|
@@ -17,12 +21,13 @@ module Isimud
|
|
17
21
|
# @overload new(attributes)
|
18
22
|
# @param[Hash] attributes event attributes
|
19
23
|
# @option attributes [Integer] :user_id ID of User associated with event
|
20
|
-
# @option attributes [String] :eventful_type
|
24
|
+
# @option attributes [String] :eventful_type type of object associated with event
|
21
25
|
# @option attributes [Integer] :eventful_id id of object associated with event
|
22
26
|
# @option attributes [String] :exchange (Isimud.events_exchange) exchange for publishing event
|
23
|
-
# @option attributes [
|
27
|
+
# @option attributes [#id] :eventful object associated with event. This sets :eventful_type and :eventful_id based
|
28
|
+
# on the class and ID of the object.
|
24
29
|
# @option attributes [String, Symbol] :type (:model) event type
|
25
|
-
# @option attributes [String] :action event action
|
30
|
+
# @option attributes [String, Symbol] :action event action
|
26
31
|
# @option attributes [Time] :occurred_at (Time.now) date and time event occurred
|
27
32
|
# @option attributes [Hash] :attributes event attributes
|
28
33
|
# @option attributes [Hash] :parameters additional parameters (deprecated)
|
@@ -38,7 +43,6 @@ module Isimud
|
|
38
43
|
else
|
39
44
|
Time.now.utc
|
40
45
|
end
|
41
|
-
@timestamp = Time.now
|
42
46
|
|
43
47
|
eventful_object = options.delete(:eventful)
|
44
48
|
|
@@ -69,13 +73,6 @@ module Isimud
|
|
69
73
|
[type.to_s, eventful_type, eventful_id, action].compact.join('.')
|
70
74
|
end
|
71
75
|
|
72
|
-
# Message ID, which is generated from the exchange, routing_key, user_id, and timestamp. This is practically
|
73
|
-
# guaranteed to be unique across all publishers.
|
74
|
-
def message_id
|
75
|
-
[exchange, routing_key, user_id, timestamp.to_i, timestamp.nsec].join(':')
|
76
|
-
end
|
77
|
-
|
78
|
-
|
79
76
|
# Return hash of data to be serialized to JSON
|
80
77
|
# @option options [Boolean] :omit_parameters when set, do not include attributes or parameters in data
|
81
78
|
# @return [Hash] data to serialize
|
@@ -115,7 +112,7 @@ module Isimud
|
|
115
112
|
def publish
|
116
113
|
data = self.serialize
|
117
114
|
log "Event#publish: #{self.inspect}"
|
118
|
-
Isimud.client.publish(exchange, routing_key, data
|
115
|
+
Isimud.client.publish(exchange, routing_key, data)
|
119
116
|
end
|
120
117
|
end
|
121
118
|
end
|
@@ -2,47 +2,94 @@ require 'isimud'
|
|
2
2
|
require 'thread'
|
3
3
|
|
4
4
|
module Isimud
|
5
|
-
|
5
|
+
# Daemon process manager for monitoring event queues.
|
6
|
+
# Known EventObserver models and their instances automatically registered upon startup. It is also possible to
|
7
|
+
# define ad-hoc queues and handlers by extending
|
8
|
+
# In addition, ad-hoc event managing may be set up by extending bind_queues() and making the appropriate subscribe
|
9
|
+
# calls directly.
|
10
|
+
#
|
11
|
+
# =====================================
|
12
|
+
# Threads created by the daemon process
|
13
|
+
# =====================================
|
14
|
+
#
|
15
|
+
# Upon startup, EventListener operates using the following threads:
|
16
|
+
# * An event processing thread that establishes consumers for message queues
|
17
|
+
# * An error counter thread that manages the error counter
|
18
|
+
# * A shutdown thread that listens for INT or TERM signals, which will trigger a graceful shutdown.
|
19
|
+
# * The main thread is put to sleep until a shutdown is required.
|
20
|
+
#
|
21
|
+
# ==================
|
22
|
+
# Registering Queues
|
23
|
+
# ==================
|
24
|
+
#
|
25
|
+
# All active instances of all known EventObserver classes (which are assumed to be ActiveRecord instances) are
|
26
|
+
# automatically loaded by the event processing thread, and their associated queues are bound. Note that queues
|
27
|
+
# and associated routing key bindings are established at the time the instance is created or modified.
|
28
|
+
# @see EventObserver.find_active_observers
|
29
|
+
#
|
30
|
+
# Each EventListener process creates an exclusive queue for monitoring the creation, modification, and destruction
|
31
|
+
# of EventObserver instances, using ModelWatcher messages.
|
32
|
+
#
|
33
|
+
# ==============
|
34
|
+
# Error Handling
|
35
|
+
# ==============
|
36
|
+
#
|
37
|
+
# Whenever an uncaught exception is rescued from a consumer handling a message, it is logged and the error counter
|
38
|
+
# is incremented. The error counter is reset periodically according to the value of +error_interval+.
|
39
|
+
# If the total number of errors logged exceeds +error_limit+, the process is terminated immediately.
|
40
|
+
# @see BunnyClient#subscribe()
|
41
|
+
#
|
42
|
+
# There are certain situations that may cause a Bunny exception to occur, such as a loss of network connection.
|
43
|
+
# Whenever a Bunny exception is rescued in the event processing thread, the Bunny session is closed (canceling all
|
44
|
+
# queue consumers), in addition to the error being counted, all Bunny channels are closed, and queues are
|
45
|
+
# reinitialized.
|
46
|
+
class EventListener
|
6
47
|
include Logging
|
48
|
+
|
49
|
+
# @!attribute [r] error_count
|
50
|
+
# @return [Integer] count of errors (uncaught exceptions) that have occurred in the current error interval
|
51
|
+
|
7
52
|
attr_reader :error_count, :error_interval, :error_limit, :name, :queues, :events_exchange, :models_exchange,
|
8
53
|
:running
|
9
54
|
|
10
|
-
DEFAULT_ERROR_LIMIT
|
11
|
-
DEFAULT_ERROR_INTERVAL
|
55
|
+
DEFAULT_ERROR_LIMIT = 10
|
56
|
+
DEFAULT_ERROR_INTERVAL = 3600
|
12
57
|
|
13
58
|
DEFAULT_EVENTS_EXCHANGE = 'events'
|
14
59
|
DEFAULT_MODELS_EXCHANGE = 'models'
|
15
60
|
|
61
|
+
# Initialize a new EventListener daemon instance
|
62
|
+
# @param [Hash] options daemon options
|
63
|
+
# @option options [Integer] :error_limit (10) maximum number of errors that are allowed to occur within error_interval
|
64
|
+
# before the process terminates
|
65
|
+
# @option options [Integer] :error_interval (3600) time interval, in seconds, before the error counter is cleared
|
66
|
+
# @option options [String] :events_exchange ('events') name of AMQP exchange used for listening to event messages
|
67
|
+
# @option options [String] :models_exchange ('models') name of AMQP exchange used for listening to EventObserver
|
68
|
+
# instance create, update, and destroy messages
|
69
|
+
# @option options [String] :name ("#{Rails.application.class.parent_name.downcase}-listener") daemon instance name.
|
16
70
|
def initialize(options = {})
|
17
71
|
default_options = {
|
18
|
-
error_limit:
|
19
|
-
error_interval:
|
20
|
-
events_exchange:
|
21
|
-
models_exchange:
|
22
|
-
name:
|
72
|
+
error_limit: Isimud.listener_error_limit || DEFAULT_ERROR_LIMIT,
|
73
|
+
error_interval: DEFAULT_ERROR_INTERVAL,
|
74
|
+
events_exchange: Isimud.events_exchange || DEFAULT_EVENTS_EXCHANGE,
|
75
|
+
models_exchange: Isimud.model_watcher_exchange || DEFAULT_MODELS_EXCHANGE,
|
76
|
+
name: "#{Rails.application.class.parent_name.downcase}-listener"
|
23
77
|
}
|
24
78
|
options.reverse_merge!(default_options)
|
25
|
-
@error_count
|
26
|
-
@observers
|
27
|
-
@observed_models
|
28
|
-
@error_limit
|
29
|
-
@error_interval
|
30
|
-
@events_exchange
|
31
|
-
@models_exchange
|
32
|
-
@name
|
33
|
-
@observer_mutex
|
34
|
-
@error_counter_mutex
|
35
|
-
@running
|
36
|
-
end
|
37
|
-
|
38
|
-
def max_errors
|
39
|
-
Isimud.listener_error_limit || DEFAULT_ERROR_LIMIT
|
40
|
-
end
|
41
|
-
|
42
|
-
def test_env?
|
43
|
-
['cucumber', 'test'].include?(Rails.env)
|
79
|
+
@error_count = 0
|
80
|
+
@observers = Hash.new
|
81
|
+
@observed_models = Set.new
|
82
|
+
@error_limit = options[:error_limit]
|
83
|
+
@error_interval = options[:error_interval]
|
84
|
+
@events_exchange = options[:events_exchange]
|
85
|
+
@models_exchange = options[:models_exchange]
|
86
|
+
@name = options[:name]
|
87
|
+
@observer_mutex = Mutex.new
|
88
|
+
@error_counter_mutex = Mutex.new
|
89
|
+
@running = false
|
44
90
|
end
|
45
91
|
|
92
|
+
# Run the daemon process. This creates the event, error counter, and shutdown threads
|
46
93
|
def run
|
47
94
|
@running = true
|
48
95
|
bind_queues and return if test_env?
|
@@ -60,8 +107,23 @@ module Isimud
|
|
60
107
|
client.close
|
61
108
|
end
|
62
109
|
|
63
|
-
#
|
110
|
+
# Hook for setting up custom queues in your application. Override in your subclass.
|
111
|
+
def bind_event_queues
|
112
|
+
end
|
113
|
+
|
114
|
+
# @private
|
64
115
|
def bind_queues
|
116
|
+
bind_observer_queues
|
117
|
+
bind_event_queues
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def test_env?
|
123
|
+
['cucumber', 'test'].include?(Rails.env)
|
124
|
+
end
|
125
|
+
|
126
|
+
def bind_observer_queues
|
65
127
|
Isimud::EventObserver.observed_models.each do |model_class|
|
66
128
|
log "EventListener: registering observers for #{model_class}"
|
67
129
|
register_observer_class(model_class)
|
@@ -72,14 +134,15 @@ module Isimud
|
|
72
134
|
end
|
73
135
|
log "EventListener: registered #{count} observers for #{model_class}"
|
74
136
|
end
|
137
|
+
client.subscribe(observer_queue) do |payload|
|
138
|
+
handle_observer_event(payload)
|
139
|
+
end
|
75
140
|
end
|
76
141
|
|
77
142
|
def has_observer?(observer)
|
78
143
|
@observers.has_key?(observer_key_for(observer.class, observer.id))
|
79
144
|
end
|
80
145
|
|
81
|
-
private
|
82
|
-
|
83
146
|
def start_shutdown_thread
|
84
147
|
shutdown_thread = Thread.new do
|
85
148
|
Thread.stop # wait until we get a TERM or INT signal.
|
@@ -100,11 +163,12 @@ module Isimud
|
|
100
163
|
while @running
|
101
164
|
begin
|
102
165
|
bind_queues
|
103
|
-
|
166
|
+
log 'EventListener: event_thread finished'
|
104
167
|
Thread.stop
|
105
168
|
rescue Bunny::Exception => e
|
106
169
|
count_error(e)
|
107
|
-
|
170
|
+
log 'EventListener: resetting queues', :warn
|
171
|
+
@observer_queue = nil
|
108
172
|
client.reset
|
109
173
|
end
|
110
174
|
end
|
@@ -123,7 +187,6 @@ module Isimud
|
|
123
187
|
end
|
124
188
|
end
|
125
189
|
|
126
|
-
# start an error counter thread that clears the error count once per hour
|
127
190
|
def start_error_counter_thread
|
128
191
|
log 'EventListener: starting error counter'
|
129
192
|
@error_count = 0
|
@@ -142,55 +205,50 @@ module Isimud
|
|
142
205
|
event = JSON.parse(payload).with_indifferent_access
|
143
206
|
action = event[:action]
|
144
207
|
log "EventListener: received observer model message: #{event.inspect}"
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
else
|
152
|
-
unregister_observer(event[:type], event[:id])
|
208
|
+
if %w(update destroy).include?(action)
|
209
|
+
unregister_observer(event[:type], event[:id])
|
210
|
+
end
|
211
|
+
if %w(create update).include?(action)
|
212
|
+
observer = event[:type].constantize.find(event[:id])
|
213
|
+
register_observer(observer) if observer.enable_listener?
|
153
214
|
end
|
154
215
|
end
|
155
216
|
|
156
|
-
#
|
157
|
-
def
|
217
|
+
# Register the observer class watcher
|
218
|
+
def register_observer_class(observer_class)
|
158
219
|
@observer_mutex.synchronize do
|
159
|
-
|
160
|
-
|
161
|
-
|
220
|
+
return if @observed_models.include?(observer_class)
|
221
|
+
@observed_models << observer_class
|
222
|
+
log "EventListener: registering observer class #{observer_class}"
|
223
|
+
observer_queue.bind(models_exchange, routing_key: "#{Isimud.model_watcher_schema}.#{observer_class.base_class.name}.*")
|
162
224
|
end
|
163
225
|
end
|
164
226
|
|
165
|
-
#
|
166
|
-
|
167
|
-
|
168
|
-
|
227
|
+
# Register an observer instance, and start listening for events on its associated queue.
|
228
|
+
# Also ensure that we are listening for observer class update events
|
229
|
+
def register_observer(observer)
|
230
|
+
@observer_mutex.synchronize do
|
231
|
+
log "EventListener: registering observer #{observer.class} #{observer.id}"
|
232
|
+
@observers[observer_key_for(observer.class, observer.id)] = observer.observe_events(client)
|
233
|
+
end
|
169
234
|
end
|
170
235
|
|
171
|
-
#
|
236
|
+
# Unregister an observer instance, and cancel consumption of messages. Any pre-fetched messages will be returned to the queue.
|
172
237
|
def unregister_observer(observer_class, observer_id)
|
173
238
|
@observer_mutex.synchronize do
|
174
239
|
log "EventListener: un-registering observer #{observer_class} #{observer_id}"
|
175
|
-
|
176
|
-
|
177
|
-
|
240
|
+
if (consumer = @observers.delete(observer_key_for(observer_class, observer_id)))
|
241
|
+
consumer.cancel
|
242
|
+
end
|
178
243
|
end
|
179
244
|
end
|
180
245
|
|
181
246
|
# Create or return the observer queue which listens for ModelWatcher events
|
182
247
|
def observer_queue
|
183
|
-
@observer_queue ||= client.create_queue(
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
def register_observer_class(observer_class)
|
188
|
-
@observer_mutex.synchronize do
|
189
|
-
return if @observed_models.include?(observer_class)
|
190
|
-
@observed_models << observer_class
|
191
|
-
log "EventListener: registering observer class #{observer_class}"
|
192
|
-
observer_queue.bind(models_exchange, routing_key: "#{Isimud.model_watcher_schema}.#{observer_class.base_class.name}.*")
|
193
|
-
end
|
248
|
+
@observer_queue ||= client.create_queue([name, 'listener', Socket.gethostname, Process.pid].join('.'),
|
249
|
+
models_exchange,
|
250
|
+
queue_options: {exclusive: true},
|
251
|
+
subscribe_options: {manual_ack: true})
|
194
252
|
end
|
195
253
|
|
196
254
|
def observer_key_for(type, id)
|