isimud 0.7.0 → 1.3.1
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 +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)
|