marconi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+
2
+ require 'active_support/core_ext/string/inflections'
3
+
4
+ module Marconi
5
+ module Broadcaster
6
+
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ base.setup
10
+ end
11
+
12
+ module ClassMethods
13
+ def setup
14
+ @master_model_name = self.name.underscore
15
+
16
+ # Callbacks
17
+ after_update :publish_update
18
+ after_create :publish_create
19
+ after_destroy :publish_destroy
20
+ end
21
+
22
+ def master_model_name
23
+ @master_model_name
24
+ end
25
+ end
26
+
27
+ def publish_update
28
+ publish('update')
29
+ end
30
+
31
+ def publish_create
32
+ publish('create')
33
+ end
34
+
35
+ def publish_destroy
36
+ publish('destroy')
37
+ end
38
+
39
+ def publish(operation)
40
+
41
+ # This is set in Receiver if it's included. Intent is to
42
+ # prevent sending messages in response to incoming messages and thus
43
+ # creating infinite loops.
44
+ return if self.class.respond_to?(:broadcasts_suppressed?) &&
45
+ self.class.broadcasts_suppressed?
46
+
47
+ fmt = "%28s %9s %s Published" % [guid,operation,Time.now.to_s]
48
+ logger.debug fmt
49
+ Marconi.log(fmt)
50
+
51
+ e = Envelope.new { |e| e.send(operation, self) }
52
+ topic = "#{Marconi.application_name}.#{self.class.master_model_name}.#{operation}"
53
+ exchange.publish(e.to_s, :topic => topic)
54
+ end
55
+
56
+ private
57
+
58
+ def exchange
59
+ Marconi.inbound
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,33 @@
1
+
2
+ module Marconi
3
+ class Config
4
+ attr_accessor :name, :short_name, :keepalive, :bunny_params, :listeners
5
+ def initialize(config_file = nil, env = 'test')
6
+
7
+ if Object.const_defined?(:Rails)
8
+ config_file ||= "#{Rails.root}/config/queues.yml"
9
+ env = Rails.env
10
+ end
11
+
12
+ unless File.exists?(config_file)
13
+ raise "Could not find #{config_file}"
14
+ end
15
+
16
+ params = YAML.load_file(config_file)
17
+
18
+ self.name = params['name'] || raise("Wait... Who am I?")
19
+ self.short_name = params['short_name'] || raise("w8... hu m i?")
20
+ self.keepalive = !!params['keepalive']
21
+ self.listeners = params['listeners'] || []
22
+ self.bunny_params = params['bunny'] && params['bunny'][env]
23
+
24
+ if bunny_params
25
+ self.bunny_params.symbolize_keys!
26
+ else
27
+ puts "Warning: No config specified for #{env}"
28
+ self.bunny_params = {}
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+ require 'active_support/core_ext/hash/conversions'
4
+ require 'marconi/smart_xml'
5
+
6
+ module Marconi
7
+ class Envelope
8
+
9
+ attr_accessor :topic
10
+
11
+ class << self
12
+
13
+ def from_xml(xml)
14
+ return nil unless xml
15
+ Envelope.new(:xml => xml)
16
+ end
17
+
18
+ end
19
+
20
+ def initialize(options = {}, &block)
21
+ if xml = options[:xml]
22
+ hash = SmartXML.parse(xml)
23
+ @topic = hash[:headers][:topic]
24
+ hash[:payload].each do |hsh|
25
+ messages << { :meta => hsh[:meta], :data => HashWithIndifferentAccess.new(hsh[:data]) }
26
+ end
27
+ else
28
+ @timestamp = options[:timestamp] || Time.now
29
+ @topic = options[:topic]
30
+ block.call(self) if block_given?
31
+ end
32
+ end
33
+
34
+ def messages
35
+ @messages ||= []
36
+ end
37
+
38
+ def contents
39
+ { :headers => headers, :payload => messages }
40
+ end
41
+
42
+ def create(model)
43
+ add_message(model, :create)
44
+ end
45
+
46
+ def update(model)
47
+ add_message(model, :update)
48
+ end
49
+
50
+ def destroy(model)
51
+ add_message(model, :destroy)
52
+ end
53
+
54
+ def override(old_guid, model)
55
+ add_message(model, :override, old_guid)
56
+ end
57
+
58
+ def to_s
59
+ contents.to_xml
60
+ end
61
+
62
+ private
63
+
64
+ def headers
65
+ { :topic => @topic }
66
+ end
67
+
68
+ def add_message(model, operation, guid = model.guid)
69
+ meta = {
70
+ :operation => operation.to_s,
71
+ :guid => guid,
72
+ :version => model.version,
73
+ :timestamp => @timestamp
74
+ }
75
+ messages << { :meta => meta, :data => HashWithIndifferentAccess.new(model.attributes) }
76
+ end
77
+
78
+ end
79
+ end
@@ -0,0 +1,179 @@
1
+ require 'bunny'
2
+
3
+ module Marconi
4
+ class Exchange
5
+
6
+ def initialize(exchange_name)
7
+ @exchange_name = exchange_name
8
+ end
9
+
10
+ def exchange_name
11
+ @exchange_name
12
+ end
13
+
14
+ def name
15
+ Marconi.config.name
16
+ end
17
+
18
+ def keepalive
19
+ Marconi.config.keepalive
20
+ end
21
+
22
+ def bunny_params
23
+ Marconi.config.bunny_params
24
+ end
25
+
26
+ DEFAULT_PUBLISH_OPTIONS = {
27
+ # Tells the server how to react if the message cannot be routed to a queue.
28
+ # If set to true, the server will return an unroutable message with a Return method.
29
+ # If set to false, the server silently drops the message.
30
+ :mandatory => true,
31
+
32
+ # Tells the server how to react if the message cannot be routed to a
33
+ # queue consumer immediately. If set to true, the server will return an
34
+ # undeliverable message with a Return method. If set to false, the
35
+ # server will queue the message, but with no guarantee that it will ever
36
+ # be consumed.
37
+ :immediate => false,
38
+
39
+ # Tells the server whether to persist the message. If set to true, the
40
+ # message will be persisted to disk and not lost if the server restarts.
41
+ # Setting to true incurs a performance penalty as there is an extra cost
42
+ # associated with disk access.
43
+ :persistent => true
44
+ }
45
+
46
+ # Example: Marconi.inbound.publish("Howdy!", :topic => 'deals.member.create')
47
+ def publish(msg, options = {})
48
+ topic = ensure_valid_publish_topic(options)
49
+ begin
50
+ connect
51
+ @exchange.publish(msg, DEFAULT_PUBLISH_OPTIONS.merge(:key => topic))
52
+ retmsg = @bunny.returned_message
53
+ raise "Invalid return payload: #{retmsg[:payload]}" unless retmsg[:payload] == :no_return
54
+ true
55
+ rescue Exception => e
56
+ Marconi.log(e)
57
+ if Marconi.backup_queue_class && !options[:recovering]
58
+ Marconi.backup_queue_class.create!(:exchange_name => exchange_name,
59
+ :topic => topic,
60
+ :body => msg)
61
+ end
62
+ false
63
+ end
64
+ end
65
+
66
+ # Example: Marconi.inbound.subscribe('foo_q', :key => 'deals.member.*') { |msg| puts msg[:payload] }
67
+ def subscribe(q_name, options = {}, &block)
68
+ q, key = get_q(q_name, options)
69
+ q.subscribe(options, &block)
70
+ end
71
+
72
+ # Example: Marconi.inbound.pop('foo_q', :key => 'deals.member.*')
73
+ def pop(q_name, options = {})
74
+ q, key = get_q(q_name, options)
75
+ msg = q.pop[:payload]
76
+ msg == :queue_empty ? nil : msg
77
+ end
78
+
79
+ # Example: Marconi.inbound.purge_q('foo_q')
80
+ # Use judiciously - this tosses all messages in the Q!
81
+ def purge_q(q_name)
82
+ connect
83
+ unless q_name.blank?
84
+ if o = exists?(:queue, q_name)
85
+ o.purge
86
+ end
87
+ end
88
+ rescue Bunny::ForcedChannelCloseError
89
+ connect(true) # Connection is fucked after this error, so it must be refreshed
90
+ raise
91
+ end
92
+
93
+ # Example: Marconi.inbound.nuke_q('foo_q')
94
+ # Use judiciously - this tosses all messages in the Q *and* nukes it
95
+ def nuke_q(q_name)
96
+ generic_nuke(:queue, q_name)
97
+ end
98
+
99
+ private
100
+
101
+ def connected?
102
+ @bunny && @bunny.status == :connected && @exchange
103
+ end
104
+
105
+ def connect(reconnect = false)
106
+ reconnect = true unless keepalive
107
+ @bunny.stop if reconnect && connected? rescue nil
108
+ if reconnect || !connected?
109
+ @bunny = Bunny.new(bunny_params)
110
+ result = @bunny.start
111
+ if result == :connected
112
+ @exchange =
113
+ @bunny.exchange(
114
+ exchange_name,
115
+
116
+ # Topic queues are broadcast queues that allow wildcard subscriptions
117
+ :type => :topic,
118
+
119
+ # Durable exchanges remain active when a server restarts.
120
+ # Non-durable exchanges (transient exchanges) are purged if/when
121
+ # a server restarts.
122
+ :durable => true
123
+ )
124
+ result
125
+ else
126
+ raise "Unable to connect to RabbitMQ: #{result}"
127
+ end
128
+ end
129
+ rescue Bunny::ProtocolError # raised by @bunny.start
130
+ @bunny = nil
131
+ raise
132
+ end
133
+
134
+ def ensure_valid_publish_topic(options)
135
+ raise "You must specify a topic to publish to!" unless topic = options[:topic]
136
+ raise "You may not publish to a topic name with a wildcard" if topic =~ /[*#]/
137
+ topic
138
+ end
139
+
140
+ DEFAULT_Q_OPTIONS = {
141
+ :durable => true,
142
+ :exclusive => false,
143
+ :auto_delete => false
144
+ }
145
+
146
+ def get_q(q_name, options)
147
+ connect
148
+ raise "You must specify a topic to subscribe to!" unless key = options[:key]
149
+ raise "You must specify a Queue Name" if q_name.blank?
150
+ q = @bunny.queue(q_name, DEFAULT_Q_OPTIONS)
151
+ q.bind(@exchange, :key => key)
152
+ [q, key]
153
+ rescue Bunny::ForcedChannelCloseError, Bunny::ProtocolError
154
+ connect(true) # Connection is fucked after this error, so it must be refreshed
155
+ raise
156
+ end
157
+
158
+ def exists?(type, name)
159
+ @bunny.send(type, name, :passive => true)
160
+ # Annoyingly, this error is thrown if the q or exchange can't be found ... yet :passive isn't
161
+ # supposed to err!! Bah.
162
+ rescue Bunny::ForcedChannelCloseError
163
+ connect(true) # Connection is fucked after this error, so it must be refreshed
164
+ nil
165
+ end
166
+
167
+ def generic_nuke(type, name)
168
+ connect
169
+ unless name.blank?
170
+ if o = exists?(type, name)
171
+ o.delete
172
+ end
173
+ end
174
+ rescue Bunny::ForcedChannelCloseError
175
+ connect(true) # Connection is fucked after this error, so it must be refreshed
176
+ raise
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,16 @@
1
+
2
+ require 'uuidtools'
3
+
4
+ module Marconi
5
+ module GUIDGenerator
6
+
7
+ extend self
8
+
9
+ def next_guid
10
+ shortname = Marconi.short_application_name[0,2]
11
+ uid = UUIDTools::UUID.random_create.to_i.to_s(36).rjust(25,'0')
12
+ "#{shortname}-#{uid}"
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,84 @@
1
+
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+
4
+ module Marconi
5
+ module Receiver
6
+
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ base.setup
10
+ end
11
+
12
+ module ClassMethods
13
+
14
+ def setup
15
+ @master_model_name = self.name.underscore
16
+ end
17
+
18
+ def master_model_name
19
+ @master_model_name
20
+ end
21
+
22
+ def register(operation, &block)
23
+ self.handlers[operation] ||= []
24
+ self.handlers[operation] << block
25
+ end
26
+
27
+ def handlers
28
+ @handlers ||= HashWithIndifferentAccess.new
29
+ end
30
+
31
+ def log_message_action(amqp_mesg, info_mesg)
32
+ fmt = "%28s %9s %s %s" %
33
+ [amqp_mesg[:meta][:guid],
34
+ amqp_mesg[:meta][:operation],
35
+ Time.now().to_s, info_mesg]
36
+ logger.debug(fmt)
37
+ Marconi.log(fmt)
38
+ end
39
+
40
+ def listen(max = nil)
41
+ q_name = "#{Marconi.application_name}.#{self.master_model_name}"
42
+ topic = "#.#{self.master_model_name}.#"
43
+ exchange.subscribe(q_name, :key => topic, :message_max => max) do |amqp_msg|
44
+ e = Envelope.from_xml(amqp_msg[:payload])
45
+ suppress_broadcasts do
46
+ e.messages.each do |message|
47
+ log_message_action(message, "Receiving")
48
+ operation = message[:meta][:operation]
49
+ next unless self.handlers[operation]
50
+ self.handlers[operation].each do |h|
51
+ h.call(message)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def suppress_broadcasts
59
+ @suppress_broadcasts = true
60
+ yield
61
+ ensure
62
+ @suppress_broadcasts = false
63
+ end
64
+
65
+ def broadcasts_suppressed?
66
+ @suppress_broadcasts
67
+ end
68
+
69
+ def purge_handlers
70
+ @handlers = nil
71
+ end
72
+
73
+ def exchange
74
+ Marconi.outbound
75
+ end
76
+ end
77
+
78
+ def log_message_action(amqp_mesg, info_mesg)
79
+ self.class.log_message_action(amqp_mesg, info_mesg)
80
+ end
81
+
82
+ end
83
+
84
+ end
@@ -0,0 +1,40 @@
1
+
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+
4
+ module Marconi
5
+ module SmartXML
6
+
7
+ class << self
8
+ def parse(xml)
9
+ return nil unless xml
10
+ hash = Hash.from_xml(xml)
11
+ return nil unless hash
12
+
13
+ # If the top-level object is a hash, rails's deserializer puts it
14
+ # under the key "hash". If it's an array, it uses "object". Simple
15
+ # values like numbers and strings can't be XML docs all by themselves
16
+ # so we don't need to consider any other cases.
17
+
18
+ indifferentize(hash['hash'] || hash['objects'])
19
+ end
20
+
21
+ private
22
+
23
+ # Convert all hashes to indifferent hashes, because otherwise we get
24
+ # subtle bugs.
25
+ def indifferentize(hash)
26
+ case hash
27
+ when Hash
28
+ new_hash = HashWithIndifferentAccess.new
29
+ hash.each do |key, value|
30
+ new_hash[key] = indifferentize(value)
31
+ end
32
+ new_hash
33
+ else
34
+ hash
35
+ end
36
+ end
37
+ end
38
+
39
+ end
40
+ end
data/lib/marconi.rb ADDED
@@ -0,0 +1,89 @@
1
+
2
+ module Marconi
3
+
4
+ extend self
5
+
6
+ attr_accessor :backup_queue_class_name, :logger
7
+
8
+ def init(config_file, env)
9
+ @config = Config.new(config_file, env)
10
+ end
11
+
12
+ # this uses eval to get the class from its name because
13
+ # otherwise rails class reloading in dev will screw everything up
14
+ def backup_queue_class
15
+ eval(backup_queue_class_name)
16
+ end
17
+
18
+ def inbound
19
+ @inbound ||= exchange('marconi.events.inbound')
20
+ end
21
+
22
+ def outbound
23
+ @outbound ||= exchange('marconi.events.outbound')
24
+ end
25
+
26
+ def error
27
+ @error ||= exchange('marconi.events.error')
28
+ end
29
+
30
+ def config
31
+ @config ||= Config.new
32
+ end
33
+
34
+ def application_name
35
+ config.name
36
+ end
37
+
38
+ def short_application_name
39
+ config.short_name
40
+ end
41
+
42
+ def listen
43
+ config.listeners.each do |class_name|
44
+ fork do
45
+
46
+ # this is to ditch any exit handlers. Minitest in ruby 1.9
47
+ # for some reason sets itself to run an empty suite at exit.
48
+ at_exit { exit! }
49
+
50
+ # Change the process name as it appears in ps/top so the proc
51
+ # is easy to identify
52
+ $0 = "ruby #{$0} Marconi.listen [#{class_name}]"
53
+
54
+ class_name.constantize.listen
55
+ end
56
+ end
57
+
58
+ Process.waitall
59
+ rescue Interrupt
60
+ Process.kill("-INT",0) # interrupt the whole process group
61
+ end
62
+
63
+ def run_recovery_loop
64
+ if backup_queue_class
65
+ backup_queue_class.find_each do |bc|
66
+ if exchange(bc.exchange_name).publish(bc.body, :topic => bc.topic, :recovering => true)
67
+ bc.destroy
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def exchange(name)
74
+ @registry ||= {}
75
+ @registry[name] ||= Exchange.new(name)
76
+ end
77
+
78
+ def log(message)
79
+ logger.info(message) if logger
80
+ end
81
+
82
+ end
83
+
84
+ require 'marconi/guid_generator'
85
+ require 'marconi/exchange'
86
+ require 'marconi/config'
87
+ require 'marconi/envelope'
88
+ require 'marconi/receiver'
89
+ require 'marconi/broadcaster'
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: marconi
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.1.0
6
+ platform: ruby
7
+ authors:
8
+ - Ken Miller
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2011-08-02 00:00:00 -07:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: bunny
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - "="
23
+ - !ruby/object:Gem::Version
24
+ version: 0.6.0
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: "0"
36
+ type: :runtime
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: mocha
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ type: :development
48
+ version_requirements: *id003
49
+ - !ruby/object:Gem::Dependency
50
+ name: shoulda
51
+ prerelease: false
52
+ requirement: &id004 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ type: :development
59
+ version_requirements: *id004
60
+ description: Implements asychronous, distributed state broadcasting for ActiveModel-like objects.
61
+ email: ken.miller@gmail.com
62
+ executables: []
63
+
64
+ extensions: []
65
+
66
+ extra_rdoc_files: []
67
+
68
+ files:
69
+ - lib/marconi/broadcaster.rb
70
+ - lib/marconi/config.rb
71
+ - lib/marconi/envelope.rb
72
+ - lib/marconi/exchange.rb
73
+ - lib/marconi/guid_generator.rb
74
+ - lib/marconi/receiver.rb
75
+ - lib/marconi/smart_xml.rb
76
+ - lib/marconi.rb
77
+ has_rdoc: true
78
+ homepage: https://github.com/kemiller/marconi
79
+ licenses: []
80
+
81
+ post_install_message:
82
+ rdoc_options: []
83
+
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ none: false
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: "0"
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ none: false
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: "0"
98
+ requirements: []
99
+
100
+ rubyforge_project: nowarning
101
+ rubygems_version: 1.6.2
102
+ signing_key:
103
+ specification_version: 3
104
+ summary: Message-based distributed state updates for Active Record
105
+ test_files: []
106
+