isimud 0.7.0 → 1.3.1

Sign up to get free protection for your applications and to get access to all the features.
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)