thechrisoshow-smqueue 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.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 0.1.0 / 2008-11-14
2
+
3
+ * Initial release
4
+ - for Craig and Rija - enjoy :D
data/Manifest.txt ADDED
@@ -0,0 +1,9 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ Rakefile
5
+ lib/rstomp.rb
6
+ lib/smqueue.rb
7
+ lib/smqueue/adapters/spread.rb
8
+ lib/smqueue/adapters/stdio.rb
9
+ lib/smqueue/adapters/stomp.rb
data/README.txt ADDED
@@ -0,0 +1,72 @@
1
+ smqueue
2
+ by Sean O'Halpin
3
+ http://github.com/seanohalpin/smqueue
4
+
5
+ == DESCRIPTION:
6
+
7
+ Implements a simple protocol for using message queues, with adapters
8
+ for ActiveMQ, Spread and stdio (for testing).
9
+
10
+ This is a bare-bones release to share with my colleagues - apologies
11
+ for the lack of documentation and tests.
12
+
13
+ == FEATURES:
14
+
15
+ * simple to use
16
+ * designed primarily for pipeline processing
17
+ * compatible with Rails
18
+ * comes with a modified stomp.rb library (rstomp.rb)
19
+
20
+ == BUGS
21
+
22
+ * you need the ruby spread client installed - should remove this
23
+
24
+ == SYNOPSIS:
25
+
26
+ require 'smqueue'
27
+ config = YAML::load(config_file)
28
+ input_queue = SMQueue(config[:input])
29
+ output_queue = SMQueue(config[:output])
30
+ queue.get do |msg|
31
+ # do something with msg
32
+ output_queue.put new_msg
33
+ end
34
+
35
+ == REQUIREMENTS:
36
+
37
+ * depends on doodle
38
+ * you need access to an ActiveMQ message broker or Spread publisher
39
+ * development uses bones gem
40
+
41
+ == INSTALL:
42
+
43
+ * sudo gem install doodle smqueue
44
+
45
+ For development:
46
+
47
+ * sudo gem install bones
48
+
49
+ == LICENSE:
50
+
51
+ (The MIT License)
52
+
53
+ Copyright (c) 2008 Sean O'Halpin
54
+
55
+ Permission is hereby granted, free of charge, to any person obtaining
56
+ a copy of this software and associated documentation files (the
57
+ 'Software'), to deal in the Software without restriction, including
58
+ without limitation the rights to use, copy, modify, merge, publish,
59
+ distribute, sublicense, and/or sell copies of the Software, and to
60
+ permit persons to whom the Software is furnished to do so, subject to
61
+ the following conditions:
62
+
63
+ The above copyright notice and this permission notice shall be
64
+ included in all copies or substantial portions of the Software.
65
+
66
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
67
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
68
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
69
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
70
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
71
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
72
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ # Look in the tasks/setup.rb file for the various options that can be
2
+ # configured in this Rakefile. The .rake files in the tasks directory
3
+ # are where the options are used.
4
+
5
+ begin
6
+ require 'bones'
7
+ Bones.setup
8
+ rescue LoadError
9
+ load 'tasks/setup.rb'
10
+ end
11
+
12
+ ensure_in_path 'lib'
13
+ require 'smqueue'
14
+
15
+ task :default => "test"
16
+
17
+ PROJ.name = 'smqueue'
18
+ PROJ.authors = "Sean O'Halpin"
19
+ PROJ.email = 'sean.ohalpin@gmail.com'
20
+ PROJ.url = 'http://github.com/seanohalpin/smqueue'
21
+ PROJ.version = SMQueue::VERSION
22
+ PROJ.rubyforge.name = 'smqueue'
23
+
24
+ PROJ.spec.opts << '--color'
25
+
26
+ # EOF
data/lib/rstomp.rb ADDED
@@ -0,0 +1,582 @@
1
+ # Copyright 2005-2006 Brian McCallister
2
+ # Copyright 2006 LogicBlaze Inc.
3
+ # Copyright 2008 Sean O'Halpin
4
+ # - refactored to use params hash
5
+ # - made more 'ruby-like'
6
+ # - use logger instead of $stderr
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ require 'io/wait'
21
+ require 'socket'
22
+ require 'thread'
23
+ require 'stringio'
24
+ require 'logger'
25
+
26
+ if $DEBUG
27
+ require 'pp'
28
+ end
29
+
30
+ module RStomp
31
+ class RStompException < Exception
32
+ end
33
+ class ConnectionError < RStompException
34
+ end
35
+ class ReceiveError < RStompException
36
+ end
37
+ class InvalidContentLengthError < RStompException
38
+ end
39
+ class TransmitError < RStompException
40
+ end
41
+ class NoListenerError < RStompException
42
+ end
43
+ class NoDataError < RStompException
44
+ end
45
+ class InvalidFrameTerminationError < RStompException
46
+ end
47
+
48
+ # Low level connection which maps commands and supports
49
+ # synchronous receives
50
+ class Connection
51
+ attr_reader :current_host, :current_port
52
+
53
+ DEFAULT_OPTIONS = {
54
+ :user => "",
55
+ :password => "",
56
+ :host => 'localhost',
57
+ :port => 61613,
58
+ :reliable => false,
59
+ :reconnect_delay => 5,
60
+ :client_id => nil,
61
+ :logfile => STDERR,
62
+ :logger => nil,
63
+ }
64
+
65
+ # make them attributes
66
+ DEFAULT_OPTIONS.each do |key, value|
67
+ attr_accessor key
68
+ end
69
+
70
+ def Connection.open(params = {})
71
+ params = DEFAULT_OPTIONS.merge(params)
72
+ Connection.new(params)
73
+ end
74
+
75
+ # Create a connection
76
+ # Options:
77
+ # - :user => ''
78
+ # - :password => ''
79
+ # - :host => 'localhost'
80
+ # - :port => 61613
81
+ # - :reliable => false (will keep retrying to send if true)
82
+ # - :reconnect_delay => 5 (seconds)
83
+ # - :client_id => nil (used in durable subscriptions)
84
+ # - :logfile => STDERR
85
+ # - :logger => Logger.new(params[:logfile])
86
+ #
87
+ def initialize(params = {})
88
+ params = DEFAULT_OPTIONS.merge(params)
89
+ @host = params[:host]
90
+ @port = params[:port]
91
+ @secondary_host = params[:secondary_host]
92
+ @secondary_port = params[:secondary_port]
93
+
94
+ @current_host = @host
95
+ @current_port = @port
96
+
97
+ @user = params[:user]
98
+ @password = params[:password]
99
+ @reliable = params[:reliable]
100
+ @reconnect_delay = params[:reconnect_delay]
101
+ @client_id = params[:client_id]
102
+ @logfile = params[:logfile]
103
+ @logger = params[:logger] || Logger.new(@logfile)
104
+
105
+ @transmit_semaphore = Mutex.new
106
+ @read_semaphore = Mutex.new
107
+ @socket_semaphore = Mutex.new
108
+
109
+ @subscriptions = {}
110
+ @failure = nil
111
+ @socket = nil
112
+ @open = false
113
+
114
+ socket
115
+ end
116
+
117
+ def socket
118
+ # Need to look into why the following synchronize does not work. (SOH: fixed)
119
+ # SOH: Causes Exception ThreadError 'stopping only thread note: use sleep to stop forever' at 235
120
+ # SOH: because had nested synchronize in _receive - take outside _receive (in receive) and seems OK
121
+ @socket_semaphore.synchronize do
122
+ s = @socket
123
+ headers = {
124
+ :user => @user,
125
+ :password => @password
126
+ }
127
+ headers['client-id'] = @client_id unless @client_id.nil?
128
+ # logger.debug "headers = #{headers.inspect} client_id = #{ @client_id }"
129
+ while s.nil? or @failure != nil
130
+ begin
131
+ #p [:connecting, :socket, s, :failure, @failure, @failure.class.ancestors, :closed, closed?]
132
+ # logger.info( { :status => :connecting, :host => host, :port => port }.inspect )
133
+ @failure = nil
134
+
135
+ s = TCPSocket.open(@current_host, @current_port)
136
+
137
+ _transmit(s, "CONNECT", headers)
138
+ @connect = _receive(s)
139
+ @open = true
140
+
141
+ # replay any subscriptions.
142
+ @subscriptions.each { |k, v| _transmit(s, "SUBSCRIBE", v) }
143
+ rescue Interrupt => e
144
+ #p [:interrupt, e]
145
+ # rescue Exception => e
146
+ rescue RStompException, SystemCallError => e
147
+ #p [:Exception, e]
148
+ @failure = e
149
+ # ensure socket is closed
150
+ begin
151
+ s.close if s
152
+ rescue Object => e
153
+ end
154
+ s = nil
155
+ @open = false
156
+
157
+ switch_host_and_port unless @secondary_host.empty?
158
+
159
+ handle_error ConnectionError, "connect failed: '#{e.message}' will retry in #{@reconnect_delay} on #{@current_host} port #{@current_port}", host.empty?
160
+ sleep(@reconnect_delay)
161
+ end
162
+ end
163
+ @socket = s
164
+ end
165
+ end
166
+
167
+ def switch_host_and_port
168
+ # Try connecting to the slave instead
169
+ # Or if the slave goes down, connect back to the master
170
+ # if it's not a reliable queue, then if the slave queue doesn't work then fail
171
+ if !@reliable && ((@current_host == @secondary_host) && (@current_port == @secondary_port))
172
+ @current_host = ''
173
+ @current_port = ''
174
+ else # switch the host from primary to secondary (or back again)
175
+ @current_host = (@current_host == @host ? @secondary_host : @host)
176
+ @current_port = (@current_port == @port ? @secondary_port : @port)
177
+ end
178
+ end
179
+
180
+ # Is this connection open?
181
+ def open?
182
+ @open
183
+ end
184
+
185
+ # Is this connection closed?
186
+ def closed?
187
+ !open?
188
+ end
189
+
190
+ # Begin a transaction, requires a name for the transaction
191
+ def begin(name, headers = {})
192
+ headers[:transaction] = name
193
+ transmit "BEGIN", headers
194
+ end
195
+
196
+ # Acknowledge a message, used then a subscription has specified
197
+ # client acknowledgement ( connection.subscribe "/queue/a", :ack => 'client' )
198
+ #
199
+ # Accepts a transaction header ( :transaction => 'some_transaction_id' )
200
+ def ack(message_id, headers = {})
201
+ headers['message-id'] = message_id
202
+ transmit "ACK", headers
203
+ end
204
+
205
+ # Commit a transaction by name
206
+ def commit(name, headers = {})
207
+ headers[:transaction] = name
208
+ transmit "COMMIT", headers
209
+ end
210
+
211
+ # Abort a transaction by name
212
+ def abort(name, headers = {})
213
+ headers[:transaction] = name
214
+ transmit "ABORT", headers
215
+ end
216
+
217
+ # Subscribe to a destination, must specify a name
218
+ def subscribe(name, headers = {}, subscription_id = nil)
219
+ headers[:destination] = name
220
+ transmit "SUBSCRIBE", headers
221
+
222
+ # Store the sub so that we can replay if we reconnect.
223
+ if @reliable
224
+ subscription_id = name if subscription_id.nil?
225
+ @subscriptions[subscription_id]=headers
226
+ end
227
+ end
228
+
229
+ # Unsubscribe from a destination, must specify a name
230
+ def unsubscribe(name, headers = {}, subscription_id = nil)
231
+ headers[:destination] = name
232
+ transmit "UNSUBSCRIBE", headers
233
+ if @reliable
234
+ subscription_id = name if subscription_id.nil?
235
+ @subscriptions.delete(subscription_id)
236
+ end
237
+ end
238
+
239
+ # Send message to destination
240
+ #
241
+ # Accepts a transaction header ( :transaction => 'some_transaction_id' )
242
+ def send(destination, message, headers = {})
243
+ headers[:destination] = destination
244
+ transmit "SEND", headers, message
245
+ end
246
+
247
+ # drain socket
248
+ def discard_all_until_eof
249
+ @read_semaphore.synchronize do
250
+ while @socket do
251
+ break if @socket.gets.nil?
252
+ end
253
+ end
254
+ end
255
+ private :discard_all_until_eof
256
+
257
+ # Close this connection
258
+ def disconnect(headers = {})
259
+ transmit "DISCONNECT", headers
260
+ discard_all_until_eof
261
+ begin
262
+ @socket.close
263
+ rescue Object => e
264
+ end
265
+ @socket = nil
266
+ @open = false
267
+ end
268
+
269
+ # Return a pending message if one is available, otherwise
270
+ # return nil
271
+ def poll
272
+ @read_semaphore.synchronize do
273
+ if @socket.nil? or !@socket.ready?
274
+ nil
275
+ else
276
+ receive
277
+ end
278
+ end
279
+ end
280
+
281
+ # Receive a frame, block until the frame is received
282
+ def receive
283
+ # The receive may fail so we may need to retry.
284
+ # TODO: use retry count?
285
+ while true
286
+ begin
287
+ s = socket
288
+ rv = _receive(s)
289
+ return rv
290
+ # rescue Interrupt
291
+ # raise
292
+ rescue RStompException, SystemCallError => e
293
+ @failure = e
294
+ handle_error ReceiveError, "receive failed: #{e.message}"
295
+ # TODO: maybe sleep here?
296
+ end
297
+ end
298
+ end
299
+
300
+ private
301
+ def _receive( s )
302
+ #logger.debug "_receive"
303
+ line = ' '
304
+ @read_semaphore.synchronize do
305
+ #logger.debug "inside semaphore"
306
+ # skip blank lines
307
+ while line =~ /^\s*$/
308
+ #logger.debug "skipping blank line " + s.inspect
309
+ line = s.gets
310
+ end
311
+ if line.nil?
312
+ # FIXME: this loses data - maybe retry here if connection returns nil?
313
+ raise NoDataError, "connection returned nil"
314
+ nil
315
+ else
316
+ #logger.debug "got message data"
317
+ Message.new do |m|
318
+ m.command = line.chomp
319
+ m.headers = {}
320
+ until (line = s.gets.chomp) == ''
321
+ k = (line.strip[0, line.strip.index(':')]).strip
322
+ v = (line.strip[line.strip.index(':') + 1, line.strip.length]).strip
323
+ m.headers[k] = v
324
+ end
325
+
326
+ if m.headers['content-length']
327
+ m.body = s.read m.headers['content-length'].to_i
328
+ # expect an ASCII NUL (i.e. 0)
329
+ c = s.getc
330
+ handle_error InvalidContentLengthError, "Invalid content length received" unless c == 0
331
+ else
332
+ m.body = ''
333
+ until (c = s.getc) == 0
334
+ m.body << c.chr
335
+ end
336
+ end
337
+ if $DEBUG
338
+ logger.debug "Message #: #{m.headers['message-id']}"
339
+ logger.debug " Command: #{m.command}"
340
+ logger.debug " Headers:"
341
+ m.headers.sort.each do |key, value|
342
+ logger.debug " #{key}: #{m.headers[key]}"
343
+ end
344
+ logger.debug " Body: [#{m.body}]\n"
345
+ end
346
+ m
347
+ #c = s.getc
348
+ #handle_error InvalidFrameTerminationError, "Invalid frame termination received" unless c == 10
349
+ end
350
+ end
351
+ end
352
+ end
353
+
354
+ private
355
+
356
+ # route all error handling through this method
357
+ def handle_error(exception_class, error_message, force_raise = !@reliable)
358
+ logger.warn error_message
359
+ # if not an internal exception, then raise
360
+ if !(exception_class <= RStompException)
361
+ force_raise = true
362
+ end
363
+ raise exception_class, error_message if force_raise
364
+ end
365
+
366
+ def transmit(command, headers = {}, body = '')
367
+ # The transmit may fail so we may need to retry.
368
+ # Maybe use retry count?
369
+ while true
370
+ begin
371
+ _transmit(socket, command, headers, body)
372
+ return
373
+ # rescue Interrupt
374
+ # raise
375
+ rescue RStompException, SystemCallError => e
376
+ @failure = e
377
+ handle_error TransmitError, "transmit '#{command}' failed: #{e.message} (#{body})"
378
+ end
379
+ # TODO: sleep here?
380
+ end
381
+ end
382
+
383
+ private
384
+ def _transmit(s, command, headers={}, body='')
385
+ msg = StringIO.new
386
+ msg.puts command
387
+ headers.each {|k, v| msg.puts "#{k}: #{v}" }
388
+ msg.puts "content-length: #{body.nil? ? 0 : body.length}"
389
+ msg.puts "content-type: text/plain; charset=UTF-8"
390
+ msg.puts
391
+ msg.write body
392
+ msg.write "\0"
393
+ if $DEBUG
394
+ msg.rewind
395
+ logger.debug "_transmit"
396
+ msg.read.each_line do |line|
397
+ logger.debug line.chomp
398
+ end
399
+ end
400
+ msg.rewind
401
+ @transmit_semaphore.synchronize do
402
+ s.write msg.read
403
+ end
404
+ end
405
+ end
406
+
407
+ # Container class for frames, misnamed technically
408
+ class Message
409
+ attr_accessor :headers, :body, :command
410
+
411
+ def initialize(&block)
412
+ yield(self) if block_given?
413
+ end
414
+
415
+ def to_s
416
+ "<#{self.class} headers=#{headers.inspect} body=#{body.inspect} command=#{command.inspect} >"
417
+ end
418
+ end
419
+
420
+ # Typical Stomp client class. Uses a listener thread to receive frames
421
+ # from the server, any thread can send.
422
+ #
423
+ # Receives all happen in one thread, so consider not doing much processing
424
+ # in that thread if you have much message volume.
425
+ class Client
426
+
427
+ # Accepts the same options as Connection.open
428
+ # Also accepts a :uri parameter of form 'stomp://host:port' or 'stomp://user:password@host:port' in place
429
+ # of :user, :password, :host and :port parameters
430
+ def initialize(params = {})
431
+ params = Connection::DEFAULT_OPTIONS.merge(params)
432
+ uri = params.delete(:uri)
433
+ if uri =~ /stomp:\/\/([\w\.]+):(\d+)/
434
+ params[:user] = ""
435
+ params[:password] = ""
436
+ params[:host] = $1
437
+ params[:port] = $2
438
+ elsif uri =~ /stomp:\/\/([\w\.]+):(\w+)@(\w+):(\d+)/
439
+ params[:user] = $1
440
+ params[:password] = $2
441
+ params[:host] = $3
442
+ params[:port] = $4
443
+ end
444
+
445
+ @id_mutex = Mutex.new
446
+ @ids = 1
447
+ @connection = Connection.open(params)
448
+ @listeners = {}
449
+ @receipt_listeners = {}
450
+ @running = true
451
+ @replay_messages_by_txn = {}
452
+ @listener_thread = Thread.start do
453
+ while @running
454
+ message = @connection.receive
455
+ break if message.nil?
456
+ case message.command
457
+ when 'MESSAGE':
458
+ if listener = @listeners[message.headers['destination']]
459
+ listener.call(message)
460
+ end
461
+ when 'RECEIPT':
462
+ if listener = @receipt_listeners[message.headers['receipt-id']]
463
+ listener.call(message)
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
469
+
470
+ # Join the listener thread for this client,
471
+ # generally used to wait for a quit signal
472
+ def join
473
+ @listener_thread.join
474
+ end
475
+
476
+ # Accepts the same options as Connection.open
477
+ def self.open(params = {})
478
+ params = Connection::DEFAULT_OPTIONS.merge(params)
479
+ Client.new(params)
480
+ end
481
+
482
+ # Begin a transaction by name
483
+ def begin(name, headers = {})
484
+ @connection.begin name, headers
485
+ end
486
+
487
+ # Abort a transaction by name
488
+ def abort(name, headers = {})
489
+ @connection.abort name, headers
490
+
491
+ # lets replay any ack'd messages in this transaction
492
+ replay_list = @replay_messages_by_txn[name]
493
+ if replay_list
494
+ replay_list.each do |message|
495
+ if listener = @listeners[message.headers['destination']]
496
+ listener.call(message)
497
+ end
498
+ end
499
+ end
500
+ end
501
+
502
+ # Commit a transaction by name
503
+ def commit(name, headers = {})
504
+ txn_id = headers[:transaction]
505
+ @replay_messages_by_txn.delete(txn_id)
506
+ @connection.commit(name, headers)
507
+ end
508
+
509
+ # Subscribe to a destination, must be passed a block taking one parameter (the message)
510
+ # which will be used as a callback listener
511
+ #
512
+ # Accepts a transaction header ( :transaction => 'some_transaction_id' )
513
+ def subscribe(destination, headers = {}, &block)
514
+ handle_error NoListenerError, "No listener given" unless block_given?
515
+ @listeners[destination] = block
516
+ @connection.subscribe(destination, headers)
517
+ end
518
+
519
+ # Unsubscribe from a channel
520
+ def unsubscribe(name, headers = {})
521
+ @connection.unsubscribe name, headers
522
+ @listeners[name] = nil
523
+ end
524
+
525
+ # Acknowledge a message, used when a subscription has specified
526
+ # client acknowledgement ( connection.subscribe "/queue/a", :ack => 'client' )
527
+ #
528
+ # Accepts a transaction header ( :transaction => 'some_transaction_id' )
529
+ def acknowledge(message, headers = {}, &block)
530
+ txn_id = headers[:transaction]
531
+ if txn_id
532
+ # lets keep around messages ack'd in this transaction in case we rollback
533
+ replay_list = @replay_messages_by_txn[txn_id]
534
+ if replay_list.nil?
535
+ replay_list = []
536
+ @replay_messages_by_txn[txn_id] = replay_list
537
+ end
538
+ replay_list << message
539
+ end
540
+ if block_given?
541
+ headers['receipt'] = register_receipt_listener(block)
542
+ end
543
+ @connection.ack(message.headers['message-id'], headers)
544
+ end
545
+
546
+ # Send message to destination
547
+ #
548
+ # If a block is given a receipt will be requested and passed to the
549
+ # block on receipt
550
+ #
551
+ # Accepts a transaction header ( :transaction => 'some_transaction_id' )
552
+ def send(destination, message, headers = {}, &block)
553
+ if block_given?
554
+ headers['receipt'] = register_receipt_listener(block)
555
+ end
556
+ @connection.send destination, message, headers
557
+ end
558
+
559
+ # Is this client open?
560
+ def open?
561
+ @connection.open?
562
+ end
563
+
564
+ # Close out resources in use by this client
565
+ def close
566
+ @connection.disconnect
567
+ @running = false
568
+ end
569
+
570
+ private
571
+ def register_receipt_listener(listener)
572
+ id = -1
573
+ @id_mutex.synchronize do
574
+ id = @ids.to_s
575
+ @ids = @ids.succ
576
+ end
577
+ @receipt_listeners[id] = listener
578
+ id
579
+ end
580
+
581
+ end
582
+ end