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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rspec +1 -2
  4. data/.ruby-version +1 -1
  5. data/.yardoc/checksums +10 -10
  6. data/.yardoc/object_types +0 -0
  7. data/.yardoc/objects/root.dat +0 -0
  8. data/Gemfile +2 -1
  9. data/Gemfile.lock +63 -73
  10. data/LICENSE.txt +19 -0
  11. data/README.md +51 -3
  12. data/Rakefile +5 -0
  13. data/doc/Isimud/BunnyClient.html +882 -179
  14. data/doc/Isimud/Client.html +236 -18
  15. data/doc/Isimud/Event.html +211 -95
  16. data/doc/Isimud/EventListener.html +325 -307
  17. data/doc/Isimud/EventObserver/ClassMethods.html +14 -14
  18. data/doc/Isimud/EventObserver.html +418 -36
  19. data/doc/Isimud/Generators/ConfigGenerator.html +2 -2
  20. data/doc/Isimud/Generators/InitializerGenerator.html +2 -2
  21. data/doc/Isimud/Generators.html +2 -2
  22. data/doc/Isimud/Logging.html +3 -3
  23. data/doc/Isimud/ModelWatcher/ClassMethods.html +3 -3
  24. data/doc/Isimud/ModelWatcher.html +2 -2
  25. data/doc/Isimud/Railtie.html +2 -2
  26. data/doc/Isimud/TestClient/Queue.html +374 -71
  27. data/doc/Isimud/TestClient.html +169 -161
  28. data/doc/Isimud.html +80 -76
  29. data/doc/_index.html +5 -2
  30. data/doc/file.LICENSE.html +73 -0
  31. data/doc/file.README.html +131 -7
  32. data/doc/file_list.html +3 -0
  33. data/doc/index.html +131 -7
  34. data/doc/method_list.html +183 -105
  35. data/doc/top-level-namespace.html +2 -2
  36. data/isimud.gemspec +18 -16
  37. data/lib/isimud/bunny_client.rb +85 -32
  38. data/lib/isimud/client.rb +23 -7
  39. data/lib/isimud/event.rb +11 -14
  40. data/lib/isimud/event_listener.rb +123 -65
  41. data/lib/isimud/event_observer.rb +70 -10
  42. data/lib/isimud/model_watcher.rb +1 -1
  43. data/lib/isimud/test_client.rb +54 -26
  44. data/lib/isimud/version.rb +1 -1
  45. data/lib/isimud.rb +1 -1
  46. data/spec/internal/app/models/company.rb +8 -10
  47. data/spec/internal/app/models/user.rb +11 -1
  48. data/spec/internal/db/schema.rb +5 -0
  49. data/spec/isimud/bunny_client_spec.rb +21 -9
  50. data/spec/isimud/client_spec.rb +40 -0
  51. data/spec/isimud/event_listener_spec.rb +50 -22
  52. data/spec/isimud/event_observer_spec.rb +107 -16
  53. data/spec/isimud/model_watcher_spec.rb +18 -23
  54. data/spec/isimud/test_client_spec.rb +43 -8
  55. data/spec/isimud_spec.rb +2 -2
  56. data/spec/spec_helper.rb +2 -0
  57. metadata +19 -35
  58. checksums.yaml.gz.sig +0 -0
  59. data/certs/gfeil.pem +0 -21
  60. data/release +0 -31
  61. data.tar.gz.sig +0 -0
  62. 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 = '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 intended for Rails applications. You can use it to define
14
- message consumption queues for ActiveRecord instances, or synchronize model updates between processes. It also provides
15
- an event listener background process for managing queues that consume messages.
16
- EOT
17
- spec.homepage = 'https://github.com/KeasInc/isimud'
18
- spec.license = 'MITNFA'
19
- spec.cert_chain = ['certs/gfeil.pem']
20
- spec.signing_key = File.expand_path("~/.ssh/gem-private_key.pem") if $0 =~ /gem\z/
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', '>= 3.2.17'
28
- spec.add_dependency 'activesupport', '>= 3.2.17'
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
@@ -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
- queue_options: {durable: true},
19
- routing_keys: routing_keys,
20
- subscribe_options: {manual_ack: true}, &block)
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
- def create_queue(queue_name, exchange_name, options = {}, &block)
24
- queue_options = options[:queue_options] || {}
25
- routing_keys = options[:routing_keys] || []
26
- subscribe_options = options[:subscribe_options] || {}
27
- log "Isimud: create_queue #{queue_name}: queue_options=#{queue_options.inspect} routing_keys=#{routing_keys.join(',')} subscribe_options=#{subscribe_options.inspect}"
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 = find_queue(queue_name, queue_options)
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 #{queue_name} received #{delivery_info.delivery_tag} routing_key: #{delivery_info.routing_key}"
34
- Thread.current['isimud_queue_name'] = 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 #{queue_name} finished with #{delivery_info.delivery_tag}, acknowledging"
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 #{queue_name} error processing #{delivery_info.delivery_tag} payload #{payload.inspect}: #{e.class.name} #{e.message}\n #{e.backtrace.join("\n ")}", :warn)
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
- exception_handler.try(:call, e)
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
- def publish(exchange, routing_key, payload, options = {})
97
- channel.topic(exchange, durable: true).publish(payload, options.merge(routing_key: routing_key, persistent: true))
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
- private
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 = {}, &method)
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
- @exception_handler = block
37
+ exception_handlers << block
34
38
  end
35
39
 
36
- def publish(exchange, routing_key, payload, options = {})
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 reconnect
46
+ def publish(exchange, routing_key, payload)
40
47
  end
41
48
 
42
- def rebind(queue_name, exchange_name, *keys)
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 class of object associated with event
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 [ActiveRecord::Base] :eventful object associated with event. This sets :eventful_type and :eventful_id.
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, message_id: message_id)
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
- class EventListener
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 = 10
11
- DEFAULT_ERROR_INTERVAL = 3600
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: Isimud.listener_error_limit || DEFAULT_ERROR_LIMIT,
19
- error_interval: DEFAULT_ERROR_INTERVAL,
20
- events_exchange: Isimud.events_exchange || DEFAULT_EVENTS_EXCHANGE,
21
- models_exchange: Isimud.model_watcher_exchange || DEFAULT_MODELS_EXCHANGE,
22
- name: "#{Rails.application.class.parent_name.downcase}-listener"
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 = 0
26
- @observers = Hash.new
27
- @observed_models = Set.new
28
- @error_limit = options[:error_limit]
29
- @error_interval = options[:error_interval]
30
- @events_exchange = options[:events_exchange]
31
- @models_exchange = options[:models_exchange]
32
- @name = options[:name]
33
- @observer_mutex = Mutex.new
34
- @error_counter_mutex = Mutex.new
35
- @running = false
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
- # Override this method to set up message observers
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
- Rails.logger.info 'EventListener: event_thread finished'
166
+ log 'EventListener: event_thread finished'
104
167
  Thread.stop
105
168
  rescue Bunny::Exception => e
106
169
  count_error(e)
107
- Rails.logger.warn 'EventListener: resetting queues'
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
- observer = event[:type].constantize.find(event[:id]) unless action == 'destroy'
146
- case
147
- when action == 'create'
148
- register_observer(observer) if observer.enable_listener?
149
- when action == 'update' && observer.enable_listener?
150
- rebind_observer(observer)
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
- # Create and bind a queue for the observer. Also ensure that we are listening for observer class update events
157
- def register_observer(observer)
217
+ # Register the observer class watcher
218
+ def register_observer_class(observer_class)
158
219
  @observer_mutex.synchronize do
159
- log "EventListener: registering observer #{observer.class} #{observer.id}"
160
- observer.observe_events(client, events_exchange)
161
- @observers[observer_key_for(observer.class, observer.id)] = observer
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
- # Update the bindings for an observer.
166
- def rebind_observer(observer)
167
- log "EventListener: rebinding observer #{observer.class} #{observer.id}"
168
- client.rebind(observer.event_queue_name, events_exchange, observer.routing_keys)
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
- # Delete a queue for an observer. This also purges all messages associated with it
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
- queue_name = observer_class.constantize.event_queue_name(observer_id)
176
- client.delete_queue(queue_name)
177
- @observers.delete(observer_key_for(observer_class, observer_id))
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("#{name}.listener", models_exchange, &method(:handle_observer_event))
184
- end
185
-
186
- # Register the observer class watcher
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)