symphony 0.3.0.pre20140327204419

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bunny'
4
+ require 'loggability'
5
+ require 'configurability'
6
+
7
+ require 'symphony' unless defined?( Symphony )
8
+ require 'symphony/mixins'
9
+
10
+
11
+ # An object class that encapsulates queueing logic for Symphony jobs.
12
+ class Symphony::Queue
13
+ extend Loggability,
14
+ Configurability,
15
+ Symphony::MethodUtilities
16
+
17
+
18
+ # Configurability defaults
19
+ CONFIG_DEFAULTS = {
20
+ broker_uri: nil,
21
+ exchange: 'symphony',
22
+ heartbeat: 'server',
23
+ }
24
+
25
+ # The default number of messages to prefetch
26
+ DEFAULT_PREFETCH = 10
27
+
28
+
29
+ # Loggability API -- set up symphony's logger
30
+ log_to :symphony
31
+
32
+ # Configurability API -- use the 'amqp' section of the config
33
+ config_key :amqp
34
+
35
+
36
+
37
+ ##
38
+ # The URL of the AMQP broker to connect to
39
+ singleton_attr_accessor :broker_uri
40
+
41
+ ##
42
+ # The name of the exchang to bind queues to.
43
+ singleton_attr_accessor :exchange
44
+
45
+ ##
46
+ # The options to pass to Bunny when setting up the session
47
+ singleton_attr_accessor :session_opts
48
+
49
+
50
+ ### Configurability API -- install the 'symphony' section of the config
51
+ ### when it's loaded.
52
+ def self::configure( config=nil )
53
+ config = self.defaults.merge( config || {} )
54
+
55
+ self.broker_uri = config.delete( :broker_uri )
56
+ self.exchange = config.delete( :exchange )
57
+ self.session_opts = config
58
+ end
59
+
60
+
61
+ ### Fetch a Hash of AMQP options.
62
+ def self::amqp_session_options
63
+ opts = self.session_opts.merge({
64
+ logger: Loggability[ Symphony ],
65
+ })
66
+ opts[:heartbeat] = opts[:heartbeat].to_sym if opts[:heartbeat].is_a?( String )
67
+
68
+ return opts
69
+ end
70
+
71
+
72
+ ### Clear any created Bunny objects
73
+ def self::reset
74
+ @session = nil
75
+ self.amqp.clear
76
+ end
77
+
78
+
79
+ ### Fetch the current session for AMQP connections.
80
+ def self::amqp_session
81
+ unless @session
82
+ options = self.amqp_session_options
83
+ if self.broker_uri
84
+ self.log.info "Using the broker URI-style config"
85
+ @session = Bunny.new( self.broker_uri, options )
86
+ else
87
+ self.log.info "Using the options hash-style config"
88
+ @session = Bunny.new( options )
89
+ end
90
+ end
91
+ return @session
92
+ end
93
+
94
+
95
+ ### Fetch a Hash that stores per-thread AMQP objects.
96
+ def self::amqp
97
+ @symphony ||= {}
98
+ return @symphony
99
+ end
100
+
101
+
102
+ ### Fetch the AMQP channel, creating one if necessary.
103
+ def self::amqp_channel
104
+ unless self.amqp[:channel]
105
+ self.log.debug "Creating a new AMQP channel"
106
+ self.amqp_session.start
107
+ channel = self.amqp_session.create_channel
108
+ self.amqp[:channel] = channel
109
+ end
110
+ return self.amqp[:channel]
111
+ end
112
+
113
+
114
+ ### Close and remove the current AMQP channel, e.g., after an error.
115
+ def self::reset_amqp_channel
116
+ if self.amqp[:channel]
117
+ self.log.info "Resetting AMQP channel."
118
+ self.amqp[:channel].close if self.amqp[:channel].open?
119
+ self.amqp.delete( :channel )
120
+ end
121
+
122
+ return self.amqp_channel
123
+ end
124
+
125
+
126
+ ### Fetch the configured AMQP exchange interface object.
127
+ def self::amqp_exchange
128
+ unless self.amqp[:exchange]
129
+ self.amqp[:exchange] = self.amqp_channel.topic( self.exchange, passive: true )
130
+ end
131
+ return self.amqp[:exchange]
132
+ end
133
+
134
+
135
+ ### Return a queue configured for the specified +task_class+.
136
+ def self::for_task( task_class )
137
+ args = [
138
+ task_class.queue_name,
139
+ task_class.acknowledge,
140
+ task_class.consumer_tag,
141
+ task_class.routing_keys,
142
+ task_class.prefetch
143
+ ]
144
+ return new( *args )
145
+ end
146
+
147
+
148
+
149
+ ### Create a new Queue with the specified configuration.
150
+ def initialize( name, acknowledge, consumer_tag, routing_keys, prefetch )
151
+ @name = name
152
+ @acknowledge = acknowledge
153
+ @consumer_tag = consumer_tag
154
+ @routing_keys = routing_keys
155
+ @prefetch = prefetch
156
+
157
+ @amqp_queue = nil
158
+ @shutting_down = false
159
+ end
160
+
161
+
162
+ ######
163
+ public
164
+ ######
165
+
166
+ # The name of the queue
167
+ attr_reader :name
168
+
169
+ # Acknowledge mode
170
+ attr_reader :acknowledge
171
+
172
+ # The tag to use when setting up consumer
173
+ attr_reader :consumer_tag
174
+
175
+ # The Array of routing keys to use when binding the queue to the exchange
176
+ attr_reader :routing_keys
177
+
178
+ # The maximum number of un-acked messages to prefetch
179
+ attr_reader :prefetch
180
+
181
+ # The Bunny::Consumer that is dispatching messages for the queue.
182
+ attr_accessor :consumer
183
+
184
+ ##
185
+ # The flag for shutting the queue down.
186
+ attr_predicate_accessor :shutting_down
187
+
188
+
189
+ ### The main work loop -- subscribe to the message queue and yield the payload and
190
+ ### associated metadata when one is received.
191
+ def wait_for_message( only_one=false, &work_callback )
192
+ raise LocalJumpError, "no work_callback given" unless work_callback
193
+ session = self.class.amqp_session
194
+
195
+ self.shutting_down = only_one
196
+ amqp_queue = self.create_amqp_queue( only_one ? 1 : self.prefetch )
197
+ self.consumer = self.create_consumer( amqp_queue, &work_callback )
198
+
199
+ self.log.debug "Subscribing to queue with consumer: %p" % [ self.consumer ]
200
+ amqp_queue.subscribe_with( self.consumer, block: true )
201
+ amqp_queue.channel.close
202
+ session.close
203
+ end
204
+
205
+
206
+ ### Create the Bunny::Consumer that will dispatch messages from the broker.
207
+ def create_consumer( amqp_queue, &work_callback )
208
+ ackmode = self.acknowledge
209
+ tag = self.consumer_tag
210
+
211
+ # Last argument is *no_ack*, so need to invert the logic
212
+ self.log.debug "Creating Bunny::Consumer for %p with tag: %s" % [ amqp_queue, tag ]
213
+ cons = Bunny::Consumer.new( amqp_queue.channel, amqp_queue, tag, !ackmode )
214
+
215
+ cons.on_delivery do |delivery_info, properties, payload|
216
+ rval = self.handle_message( delivery_info, properties, payload, &work_callback )
217
+ self.log.debug "Done with message %s. Session is %s" %
218
+ [ delivery_info.delivery_tag, self.class.amqp_session.closed? ? "closed" : "open" ]
219
+ cons.cancel if self.shutting_down?
220
+ end
221
+
222
+ cons.on_cancellation do
223
+ self.log.warn "Consumer cancelled."
224
+ self.shutdown
225
+ end
226
+
227
+ return cons
228
+ end
229
+
230
+
231
+ ### Create the AMQP queue from the task class and bind it to the configured exchange.
232
+ def create_amqp_queue( prefetch_count=DEFAULT_PREFETCH )
233
+ exchange = self.class.amqp_exchange
234
+ channel = self.class.amqp_channel
235
+
236
+ begin
237
+ queue = channel.queue( self.name, passive: true )
238
+ channel.prefetch( prefetch_count )
239
+ self.log.info "Using pre-existing queue: %s" % [ self.name ]
240
+ return queue
241
+ rescue Bunny::NotFound => err
242
+ self.log.info "%s; using an auto-delete queue instead." % [ err.message ]
243
+ channel = self.class.reset_amqp_channel
244
+ channel.prefetch( prefetch_count )
245
+
246
+ queue = channel.queue( self.name, auto_delete: true )
247
+ self.routing_keys.each do |key|
248
+ self.log.info " binding queue %s to the %s exchange with topic key: %s" %
249
+ [ self.name, exchange.name, key ]
250
+ queue.bind( exchange, routing_key: key )
251
+ end
252
+
253
+ return queue
254
+ end
255
+ end
256
+
257
+
258
+ ### Handle each subscribed message.
259
+ def handle_message( delivery_info, properties, payload, &work_callback )
260
+ metadata = {
261
+ delivery_info: delivery_info,
262
+ properties: properties,
263
+ content_type: properties[:content_type],
264
+ }
265
+ rval = work_callback.call( payload, metadata )
266
+ return self.ack_message( delivery_info.delivery_tag, rval )
267
+
268
+ # Re-raise errors from AMQP
269
+ rescue Bunny::Exception => err
270
+ self.log.error "%p while handling a message: %s" % [ err.class, err.message ]
271
+ self.log.debug " " + err.backtrace.join( "\n " )
272
+ raise
273
+
274
+ rescue => err
275
+ self.log.error "%p while handling a message: %s" % [ err.class, err.message ]
276
+ self.log.debug " " + err.backtrace.join( "\n " )
277
+ return self.ack_message( delivery_info.delivery_tag, false, false )
278
+ end
279
+
280
+
281
+ ### Signal a acknowledgement or rejection for a message.
282
+ def ack_message( tag, success, try_again=true )
283
+ return unless self.acknowledge
284
+
285
+ channel = self.consumer.channel
286
+
287
+ if success
288
+ self.log.debug "ACKing message %s" % [ tag ]
289
+ channel.acknowledge( tag )
290
+ else
291
+ self.log.debug "NACKing message %s %s retry" % [ tag, try_again ? 'with' : 'without' ]
292
+ channel.reject( tag, try_again )
293
+ end
294
+
295
+ return success
296
+ end
297
+
298
+
299
+ ### Close the AMQP session associated with this queue.
300
+ def shutdown
301
+ self.shutting_down = true
302
+ self.consumer.cancel
303
+ end
304
+
305
+
306
+ ### Forcefully halt the queue.
307
+ def halt
308
+ self.shutting_down = true
309
+ self.consumer.channel.close
310
+ end
311
+
312
+ end # class Symphony::Queue
313
+
@@ -0,0 +1,98 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'loggability'
5
+
6
+ require 'symphony' unless defined?( Symphony )
7
+ require 'symphony/mixins'
8
+
9
+
10
+ # A mixin for adding handlers for multiple topic keys to a Task.
11
+ module Symphony::Routing
12
+ extend Loggability
13
+ log_to :symphony
14
+
15
+ ### Add some instance data to inheriting +subclass+es.
16
+ def self::included( mod )
17
+ self.log.info "Adding routing to %p" % [ mod ]
18
+ super
19
+ mod.extend( Symphony::MethodUtilities )
20
+ mod.extend( Symphony::Routing::ClassMethods )
21
+ mod.singleton_attr_accessor( :routes )
22
+ mod.routes = Hash.new {|h,k| h[k] = [] }
23
+ end
24
+
25
+
26
+ # Methods to add to including classes
27
+ module ClassMethods
28
+
29
+ ### Register an event pattern and a block to execute when an event
30
+ ### matching that pattern is received.
31
+ def on( *route_patterns, &block )
32
+ route_patterns.each do |pattern|
33
+ methodobj = self.make_handler_method( pattern, &block )
34
+ self.routing_keys << pattern
35
+
36
+ pattern_re = self.make_routing_pattern( pattern )
37
+ self.routes[ pattern_re ] << methodobj
38
+ end
39
+ end
40
+
41
+
42
+ ### Install the given +block+ as an instance method of the receiver, using
43
+ ### the given +pattern+ to derive the name, and return it as an UnboundMethod
44
+ ### object.
45
+ def make_handler_method( pattern, &block )
46
+ methname = self.make_handler_method_name( pattern, block )
47
+ self.log.info "Setting up #%s as a handler for %s" % [ methname, pattern ]
48
+ define_method( methname, &block )
49
+ return self.instance_method( methname )
50
+ end
51
+
52
+
53
+ ### Return the name of the method that the given +block+ should be installed
54
+ ### as, derived from the specified +pattern+.
55
+ def make_handler_method_name( pattern, block )
56
+ _, line = block.source_location
57
+ pattern = pattern.
58
+ gsub( /#/, 'hash' ).
59
+ gsub( /\*/, 'star' ).
60
+ gsub( /\./, '_' )
61
+
62
+ return "on_%s_%d" % [ pattern, line ]
63
+ end
64
+
65
+
66
+ ### Return a regular expression that will match messages matching the given
67
+ ### +routing_key+.
68
+ def make_routing_pattern( routing_key )
69
+ re_string = routing_key.gsub( /\./, '\\.' )
70
+ re_string = re_string.gsub( /\*/, '([^\.]*)' )
71
+ re_string = re_string.gsub( /#/, '(.*)' )
72
+
73
+ return Regexp.compile( re_string )
74
+ end
75
+
76
+ end # module ClassMethods
77
+
78
+
79
+ ### Route the work based on the blocks registered with 'on'.
80
+ def work( payload, metadata )
81
+ key = metadata[:delivery_info].routing_key
82
+ self.log.debug "Routing a %s message..." % [ key ]
83
+
84
+ blocks = self.class.routes.inject([]) do |accum, (re, re_blocks)|
85
+ accum += re_blocks if re.match( key )
86
+ accum
87
+ end
88
+
89
+ self.log.debug " calling %d block/s" % [ blocks.length ]
90
+ return blocks.all? do |block|
91
+ block.bind( self ).call( payload, metadata )
92
+ end
93
+ end
94
+
95
+
96
+ end # module Symphony::Routing
97
+
98
+
@@ -0,0 +1,107 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'symphony'
5
+
6
+
7
+ # A module containing signal-handling logic for both tasks and the Symphony
8
+ # daemon.
9
+ module Symphony::SignalHandling
10
+
11
+
12
+ ### Wrap a block in signal-handling.
13
+ def with_signal_handler( *signals )
14
+ self.set_up_signal_handling
15
+ self.set_signal_traps( *signals )
16
+ self.start_signal_handler
17
+
18
+ return yield
19
+
20
+ ensure
21
+ self.stop_signal_handler
22
+ self.reset_signal_traps( *signals )
23
+ end
24
+
25
+
26
+ ### Set up data structures for signal handling.
27
+ def set_up_signal_handling
28
+ # Self-pipe for deferred signal-handling (ala djb:
29
+ # http://cr.yp.to/docs/selfpipe.html)
30
+ reader, writer = IO.pipe
31
+ reader.close_on_exec = true
32
+ writer.close_on_exec = true
33
+ @selfpipe = { reader: reader, writer: writer }
34
+
35
+ # Set up a global signal queue
36
+ Thread.main[:signal_queue] = []
37
+ end
38
+
39
+
40
+ ### The body of the signal handler. Wait for at least one signal to arrive and
41
+ ### handle it. This should be called inside a loop, either in its own thread or
42
+ ### in another loop that doesn't block anywhere else.
43
+ def wait_for_signals
44
+
45
+ # Wait on the selfpipe for signals
46
+ self.log.debug " waiting for the selfpipe"
47
+ fds = IO.select( [@selfpipe[:reader]] )
48
+ begin
49
+ rval = @selfpipe[:reader].read_nonblock( 11 )
50
+ self.log.debug " read from the selfpipe: %p" % [ rval ]
51
+ rescue Errno::EAGAIN, Errno::EINTR => err
52
+ self.log.debug " %p: %s!" % [ err.class, err.message ]
53
+ # ignore
54
+ end
55
+
56
+ # Look for any signals that arrived and handle them
57
+ while sig = Thread.main[:signal_queue].shift
58
+ self.log.debug " got a queued signal: %p" % [ sig ]
59
+ self.handle_signal( sig )
60
+ end
61
+ end
62
+
63
+
64
+ ### Wake the main thread up through the self-pipe.
65
+ ### Note: since this is a signal-handler method, it needs to be re-entrant.
66
+ def wake_up
67
+ @selfpipe[:writer].write_nonblock('.')
68
+ rescue Errno::EAGAIN
69
+ # Ignore.
70
+ rescue Errno::EINTR
71
+ # Repeated signal. :TODO: Does this need a counter?
72
+ retry
73
+ end
74
+
75
+
76
+ ### Set up signal handlers for common signals that will shut down, restart, etc.
77
+ def set_signal_traps( *signals )
78
+ self.log.debug "Setting up deferred signal handlers."
79
+ signals.each do |sig|
80
+ Signal.trap( sig ) do
81
+ Thread.main[:signal_queue] << sig
82
+ self.wake_up
83
+ end
84
+ end
85
+ end
86
+
87
+
88
+ ### Set all signal handlers to ignore.
89
+ def ignore_signals( *signals )
90
+ self.log.debug "Ignoring signals."
91
+ signals.each do |sig|
92
+ Signal.trap( sig, :IGNORE )
93
+ end
94
+ end
95
+
96
+
97
+ ### Set the signal handlers back to their defaults.
98
+ def reset_signal_traps( *signals )
99
+ self.log.debug "Restoring default signal handlers."
100
+ signals.each do |sig|
101
+ Signal.trap( sig, :DEFAULT )
102
+ end
103
+ end
104
+
105
+
106
+ end # module Symphony::SignalHandling
107
+