marconi 0.1.0

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