alchemy-flux 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9e0b6e71da840d69a7c637a0cb924df1c63fcf53
4
+ data.tar.gz: cb5572b0ce8972212d500e7f53e3234f9f7659f0
5
+ SHA512:
6
+ metadata.gz: eb865f0288c6b2c3de1f4947b4440b35727bc48d1bb7d8f3029cbcebf5b70784384f487924fd66ff7a7e25723528ef59c88d77c8078c89c5f05cf16dd8c6930e
7
+ data.tar.gz: 725f8ee5b7ce199d5ea70fdc913c60aff118f085f7d76085a175e16f42161c7d126eceb10b550ac1f08245bbf83f90bc0868cb81c2dd47972eb5ab9287112907
@@ -0,0 +1,409 @@
1
+ require 'time'
2
+ require 'amqp'
3
+ require "uuidtools"
4
+ require 'msgpack'
5
+
6
+ require 'alchemy-flux/flux_rack_handler.rb'
7
+
8
+ # Alchemy Flux module
9
+ module AlchemyFlux
10
+
11
+ # Error created when a Service message times out
12
+ class TimeoutError < StandardError; end
13
+
14
+ # Error created when a Message is unable to be delivered to a service
15
+ class MessageNotDeliveredError < StandardError; end
16
+
17
+ # Error used by a service when they wish the calling message to be NACKed *dangerous*
18
+ class NAckError < StandardError; end
19
+
20
+ # An Alchemy Flux Service
21
+ class Service
22
+
23
+ # The current state of the Service, either *stopped* or *started*
24
+ attr_reader :state
25
+
26
+ # The outgoing message transactions
27
+ attr_reader :transactions
28
+
29
+ # The incoming number of messages being processed
30
+ attr_reader :processing_messages
31
+
32
+ # Generate a UUID string
33
+ def self.generateUUID
34
+ UUIDTools::UUID.random_create.to_i.to_s(16).ljust(32,'0')
35
+ end
36
+
37
+ # Create a AlchemyFlux service instance
38
+ #
39
+ # +name+ the name of the service being created
40
+ # +options+
41
+ #
42
+ def initialize(name, options = {}, &block)
43
+ @name = name
44
+ @options = {
45
+ ampq_uri: 'amqp://localhost',
46
+ prefetch: 20,
47
+ timeout: 1000,
48
+ threadpool_size: 500,
49
+ resource_paths: []
50
+ }.merge(options)
51
+
52
+ @service_fn = block || Proc.new { |message| "" }
53
+
54
+ @uuid = "#{@name}.#{AlchemyFlux::Service.generateUUID()}"
55
+ @transactions = {}
56
+ @processing_messages = 0
57
+
58
+ @response_queue_name = @uuid
59
+ @service_queue_name = @name
60
+ @state = :stopped
61
+ end
62
+
63
+ # overriding inspect
64
+ def inspect
65
+ to_s
66
+ end
67
+
68
+ # overriding to_s
69
+ def to_s
70
+ "AlchemyFlux::Service(#{@name.inspect}, #{@options.inspect})"
71
+ end
72
+
73
+ # LIFE CYCLE
74
+
75
+
76
+ # Start the EventMachine and AMQP connections for all Services
77
+ #
78
+ # The application has two or more threads
79
+ # 1. The Controller Thread (e.g. the rspec thread)
80
+ # 2. The EM Thread
81
+ # 3. The EM defer Threads
82
+ #
83
+ # When we start a Service we do it in a Thread so that it will not block the calling Thread
84
+ #
85
+ # When the FIRST Service is started EM.run initialises in that Thread
86
+ # When the second Service is initialises the block is executed in the new thread,
87
+ # but all the callbacks will be executed in the EM thread
88
+ #
89
+ def self.start(ampq_uri = 'amqp://localhost', threadpool_size=500)
90
+ return if EM.reactor_running?
91
+ start_blocker = Queue.new
92
+ Thread.new do
93
+ Thread.current["name"] = "EM Thread" if EM.reactor_thread?
94
+ Thread.current.abort_on_exception = true
95
+ EM.threadpool_size = threadpool_size
96
+ AMQP.start(ampq_uri) do |connection|
97
+ @@connection = connection
98
+ @@connection.on_error do |conn, connection_close|
99
+ message = "Channel exception: [#{connection_close.reply_code}] #{connection_close.reply_text}"
100
+ puts message
101
+ raise message
102
+ end
103
+ start_blocker << :unblock
104
+ end
105
+ end
106
+ start_blocker.pop
107
+ end
108
+
109
+ # Stop EventMachine and the
110
+ def self.stop
111
+ return if !EM.reactor_running?
112
+ stop_blocker = Queue.new
113
+
114
+ #last tick
115
+ AMQP.stop do
116
+ EM.stop_event_loop
117
+ stop_blocker << :unblock
118
+ end
119
+ stop_blocker.pop
120
+ sleep(0.05) # to ensure it finished
121
+ end
122
+
123
+ # start the service
124
+ def start
125
+ return if @state != :stopped
126
+
127
+ Service.start(@options[:ampq_uri], @options[:threadpool_size])
128
+ EM.run do
129
+
130
+ @channel = AMQP::Channel.new(@@connection)
131
+
132
+ @channel.on_error do |ch, channel_close|
133
+ message = "Channel exception: [#{channel_close.reply_code}] #{channel_close.reply_text}"
134
+ puts message
135
+ raise message
136
+ end
137
+
138
+ @channel.prefetch(@options[:prefetch])
139
+ @channel.auto_recovery = true
140
+
141
+ @service_queue = @channel.queue( @service_queue_name, {:durable => true})
142
+ @service_queue.subscribe({:ack => true}) do |metadata, payload|
143
+ payload = MessagePack.unpack(payload)
144
+ process_service_queue_message(metadata, payload)
145
+ end
146
+
147
+ response_queue = @channel.queue(@response_queue_name, {:exclusive => true, :auto_delete => true})
148
+ response_queue.subscribe({}) do |metadata, payload|
149
+ payload = MessagePack.unpack(payload)
150
+ process_response_queue_message(metadata, payload)
151
+ end
152
+
153
+ @channel.default_exchange.on_return do |basic_return, frame, payload|
154
+ payload = MessagePack.unpack(payload)
155
+ process_returned_message(basic_return, frame.properties, payload)
156
+ end
157
+
158
+ # RESOURCES HANDLE
159
+ @resources_exchange = @channel.topic("resources.exchange", {:durable => true})
160
+ @resources_exchange.on_return do |basic_return, frame, payload|
161
+ payload = MessagePack.unpack(payload)
162
+ process_returned_message(basic_return, frame.properties, payload)
163
+ end
164
+
165
+ bound_resources = 0
166
+ for resource_path in @options[:resource_paths]
167
+ binding_key = "#{path_to_routing_key(resource_path)}.#"
168
+ @service_queue.bind(@resources_exchange, :key => binding_key) {
169
+ bound_resources += 1
170
+ }
171
+ end
172
+ begin
173
+ # simple loop to wait for the resources to be bound
174
+ sleep(0.01)
175
+ end until bound_resources == @options[:resource_paths].length
176
+
177
+ @state = :started
178
+ end
179
+ end
180
+
181
+ # Stop the Service
182
+ #
183
+ # This method:
184
+ # * Stops receiving new messages
185
+ # * waits for processing incoming and outgoing messages to be completed
186
+ # * close the channel
187
+ def stop
188
+ return if @state != :started
189
+ # stop receiving new incoming messages
190
+ @service_queue.unsubscribe
191
+ # only stop the service if all incoming and outgoing messages are complete
192
+ decisecond_timeout = @options[:timeout]/100
193
+ waited_deciseconds = 0 # guarantee that this loop will stop
194
+ while (@transactions.length > 0 || @processing_messages > 0) && waited_deciseconds < decisecond_timeout
195
+ sleep(0.1) # wait a decisecond to check the incoming and outgoing messages again
196
+ waited_deciseconds += 1
197
+ end
198
+
199
+ @channel.close
200
+ @state = :stopped
201
+ end
202
+
203
+ # END OF LIFE CYCLE
204
+
205
+
206
+
207
+ private
208
+ # RECIEVING MESSAGES
209
+
210
+ # process messages on the service queue
211
+ def process_service_queue_message(metadata, payload)
212
+
213
+ service_to_reply_to = metadata.reply_to
214
+ message_replying_to = metadata.message_id
215
+ this_message_id = AlchemyFlux::Service.generateUUID()
216
+ delivery_tag = metadata.delivery_tag
217
+ interaction_id = payload['headers']['x-interaction-id']
218
+
219
+ operation = proc {
220
+ @processing_messages += 1
221
+ begin
222
+ response = @service_fn.call(payload)
223
+ {
224
+ 'status_code' => response['status_code'] || 200,
225
+ 'headers' => response['headers'] || { 'x-interaction-id' => interaction_id},
226
+ 'body' => response['body'] || ""
227
+ }
228
+ rescue AlchemyFlux::NAckError => e
229
+ AlchemyFlux::NAckError
230
+ rescue Exception => e
231
+ puts "Service Fn Error " + e.inspect
232
+
233
+ # Returning Hoodoo formatted Error code (just in case service doesnt handle the error, it should!)
234
+ {
235
+ 'status_code' => 500,
236
+ 'headers' => {'Content-Type' => 'application/json; charset=utf-8'},
237
+ 'body' => {
238
+ 'kind' => "Errors",
239
+ 'id' => AlchemyFlux::Service.generateUUID(),
240
+ 'created_at' => Time.now.utc.iso8601,
241
+ 'interaction_id' => interaction_id,
242
+ 'errors' => [{'code' => 'platform.fault', 'message' => 'An unexpected error occurred'}]
243
+ }
244
+ }
245
+ end
246
+ }
247
+
248
+ callback = proc { |result|
249
+
250
+ if result == AlchemyFlux::NAckError
251
+ @service_queue.reject(delivery_tag)
252
+ else
253
+ options = {
254
+ :message_id => this_message_id,
255
+ :correlation_id => message_replying_to,
256
+ :type => 'http_response'
257
+ }
258
+ send_message(@channel.default_exchange, service_to_reply_to, result, options)
259
+ @processing_messages -= 1
260
+ @service_queue.acknowledge(delivery_tag)
261
+ end
262
+ }
263
+
264
+ EventMachine.defer(operation, callback)
265
+ end
266
+
267
+ # process a response message
268
+ #
269
+ # If a message is put on this services response queue
270
+ # its response will be pushed onto the blocking queue
271
+ def process_response_queue_message(metadata, payload)
272
+ response_queue = @transactions.delete metadata.correlation_id
273
+ response_queue << payload if response_queue
274
+ end
275
+
276
+ # process a returned message
277
+ #
278
+ # If a message is sent to a queue that cannot be found,
279
+ # rabbitmq returns that message to this method
280
+ def process_returned_message(basic_return, metadata, payload)
281
+ response_queue = @transactions.delete metadata[:message_id]
282
+ response_queue << MessageNotDeliveredError if response_queue
283
+ end
284
+
285
+ # END OF RECIEVING MESSAGES
286
+
287
+ public
288
+
289
+ # SENDING MESSAGES
290
+
291
+ # send a message to an exchange with routing key
292
+ #
293
+ # *exchange*:: A AMQP exchange
294
+ # *routing_key*:: The routing key to use
295
+ # *message*:: The message to be sent
296
+ # *options*:: The message options
297
+ def send_message(exchange, routing_key, message, options)
298
+ message_options = options.merge({:routing_key => routing_key})
299
+ message = MessagePack.pack(message)
300
+ EventMachine.next_tick do
301
+ exchange.publish message, message_options
302
+ end
303
+ end
304
+
305
+ # send a message to a service
306
+ #
307
+ # *service_name*:: the name of the service
308
+ # *message*:: the message to be sent
309
+ #
310
+ # This method can optionally take a block which will be executed asynchronously and yielded the response
311
+ def send_message_to_service(service_name, message)
312
+ if block_given?
313
+ EventMachine.defer do
314
+ yield send_message_to_service(service_name, message)
315
+ end
316
+ else
317
+ send_HTTP_request_message(@channel.default_exchange, service_name, message)
318
+ end
319
+ end
320
+
321
+ # send a message to a resource
322
+ #
323
+ # *message*:: the message to be sent to the *path* in the message
324
+ #
325
+ # This method can optionally take a block which will be executed asynchronously and yielded the response
326
+ def send_message_to_resource(message)
327
+ routing_key = path_to_routing_key(message['path'])
328
+ if block_given?
329
+ EventMachine.defer do
330
+ yield send_message_to_resource(message)
331
+ end
332
+ else
333
+ send_HTTP_request_message(@resources_exchange, routing_key, message)
334
+ end
335
+ end
336
+
337
+
338
+ private
339
+
340
+ # Takes a path and converts it into a routing key
341
+ #
342
+ # *path*:: path string
343
+ #
344
+ # For example, path '/test/path' will convert to routing key 'test.path'
345
+ def path_to_routing_key(path)
346
+ new_path = ""
347
+ path.split('').each_with_index do |c,i|
348
+ if c == '/' and i != 0 and i != path.length-1
349
+ new_path += '.'
350
+ elsif c != '/'
351
+ new_path += c
352
+ end
353
+ end
354
+ new_path
355
+ end
356
+
357
+ # send a HTTP message to an exchange with routing key
358
+ #
359
+ # *exchange*:: A AMQP exchange
360
+ # *routing_key*:: The routing key to use
361
+ # *message*:: The message to be sent
362
+ def send_HTTP_request_message(exchange, routing_key, message)
363
+
364
+ http_message = {
365
+ 'session_id' => message['session_id'],
366
+ 'scheme' => message['protocol'] || 'http',
367
+ 'host' => message['hostname'] || 'localhost',
368
+ 'port' => message['port'] || 8080,
369
+ 'path' => message['path'] || "/",
370
+ 'query' => message['query'] || {},
371
+ 'verb' => message['verb'] || "GET",
372
+ 'headers' => message['headers'] || {},
373
+ 'body' => message['body'] || ""
374
+ }
375
+
376
+ if !http_message['headers']['x-interaction-id']
377
+ http_message['headers']['x-interaction-id'] = AlchemyFlux::Service.generateUUID()
378
+ end
379
+
380
+ message_id = AlchemyFlux::Service.generateUUID()
381
+
382
+ http_message_options = {
383
+ message_id: message_id,
384
+ type: 'http_request',
385
+ reply_to: @response_queue_name,
386
+ content_encoding: '8bit',
387
+ content_type: 'application/octet-stream',
388
+ expiration: @options[:timeout],
389
+ mandatory: true
390
+ }
391
+
392
+ response_queue = Queue.new
393
+ @transactions[message_id] = response_queue
394
+
395
+ send_message(exchange, routing_key, http_message, http_message_options)
396
+
397
+ EventMachine.add_timer(@options[:timeout]/1000.0) do
398
+ response_queue = @transactions.delete message_id
399
+ response_queue << TimeoutError if response_queue
400
+ end
401
+
402
+ response_queue.pop
403
+ end
404
+
405
+ # END OF SENDING MESSAGES
406
+
407
+ end
408
+
409
+ end
@@ -0,0 +1,159 @@
1
+ # This file creates a Service that Talks Rack
2
+ require 'rack'
3
+ require 'alchemy-flux'
4
+
5
+ # The Rack namespace
6
+ module Rack
7
+ # The Rack Handlers namespace
8
+ module Handler
9
+ # Alchemy Rack handler
10
+ class AlchemyFlux
11
+
12
+
13
+
14
+ # Start the app server with the supplied Rack application and options
15
+ #
16
+ # +app+ [Rack Application] The Application to run.
17
+ # +options+ [Hash] The options to start the server with.
18
+ def self.run(app, options={})
19
+ start(app)
20
+
21
+ puts "Started #{@@service.inspect}"
22
+
23
+ Signal.trap("INT") do
24
+ puts "Stopping #{@@service.inspect}"
25
+ stop
26
+ end
27
+
28
+ Signal.trap("TERM") do
29
+ puts "Stopping #{@@service.inspect}"
30
+ stop
31
+ end
32
+
33
+ EM.reactor_thread.join
34
+ end
35
+
36
+ # start the service for rack
37
+ def self.start(app)
38
+ service_name = ENV['SERVICE_NAME']
39
+ raise RuntimeError.new("Require SERVICE_NAME environment variable") if !service_name
40
+
41
+ options = {
42
+ ampq_uri: ENV['AMQ_URI'] || 'amqp://localhost',
43
+ prefetch: ENV['PREFETCH'] || 20,
44
+ timeout: ENV['TIMEOUT'] || 30000,
45
+ threadpool_size: ENV['THREADPOOL_SIZE'] || 500,
46
+ resource_paths: (ENV['RESOURCE_PATHS'] || '').split(',')
47
+ }
48
+
49
+ if options[:prefetch] > options[:threadpool_size]
50
+ puts "WARNING: 'prefect' is greater than the available threads which may cause performance blocking problems"
51
+ end
52
+
53
+ @@service = ::AlchemyFlux::Service.new(service_name, options) do |message|
54
+ rack_env = create_rack_env(message)
55
+
56
+ # add Alchemy Service so the app may call other services
57
+ rack_env['alchemy.service'] = @@service
58
+
59
+ status, headers, body = app.call(rack_env)
60
+
61
+ # process the body into a single response string
62
+ body.close if body.respond_to?(:close)
63
+ response = ""
64
+ body.each { |part| response << part }
65
+
66
+ {
67
+ 'status_code' => status,
68
+ 'headers' => headers,
69
+ 'body' => response
70
+ }
71
+ end
72
+
73
+
74
+ @@service.start
75
+ end
76
+
77
+ # stops the app service
78
+ def self.stop
79
+ @@service.stop
80
+ EM.stop
81
+ end
82
+
83
+ # create the environment hash to be sent to the app
84
+ def self.create_rack_env(message)
85
+
86
+ stream = StringIO.new(message['body'])
87
+ stream.set_encoding(Encoding::ASCII_8BIT)
88
+
89
+
90
+ # Full description of rack env http://www.rubydoc.info/github/rack/rack/master/file/SPEC
91
+ rack_env = {}
92
+
93
+ # CGI-like (adopted from PEP333) variables
94
+
95
+ # The HTTP request method, such as “GET” or “POST”
96
+ rack_env['REQUEST_METHOD'] = message['verb'].to_s.upcase
97
+
98
+ # This is an empty string to correspond with the “root” of the server.
99
+ rack_env['SCRIPT_NAME'] = ''
100
+
101
+ # The remainder of the request URL's “path”, designating the virtual “location” of the request's target within the application.
102
+ rack_env['PATH_INFO'] = message['path']
103
+
104
+ # The portion of the request URL that follows the ?, if any
105
+ rack_env['QUERY_STRING'] = Rack::Utils.build_query(message['query'])
106
+
107
+ # Used to complete the URL
108
+ rack_env['SERVER_NAME'] = message['host']
109
+ rack_env['SERVER_PORT'] = message['port'].to_s
110
+
111
+
112
+ # Headers are added to the rack env as described by RFC3875 https://www.ietf.org/rfc/rfc3875
113
+ if message['headers'].is_a? Hash
114
+ message['headers'].each do |name, value|
115
+ name = "HTTP_" + name.to_s.upcase.gsub(/[^A-Z0-9]/,'_')
116
+ rack_env[name] = value.to_s
117
+ end
118
+ end
119
+
120
+ # The environment must not contain the keys HTTP_CONTENT_TYPE or HTTP_CONTENT_LENGTH (use the versions without HTTP_)
121
+ rack_env['CONTENT_TYPE'] = rack_env['HTTP_CONTENT_TYPE'] || 'application/octet-stream'
122
+ rack_env['CONTENT_LENGTH'] = rack_env['HTTP_CONTENT_LENGTH'] || stream.length.to_s
123
+ rack_env.delete('HTTP_CONTENT_TYPE')
124
+ rack_env.delete('HTTP_CONTENT_LENGTH')
125
+
126
+
127
+ # Rack-specific variables
128
+
129
+ # The Array representing this version of Rack See Rack::VERSION
130
+ rack_env['rack.version'] = Rack::VERSION
131
+
132
+ # http or https, depending on the request URL.
133
+ rack_env['rack.url_scheme'] = message['scheme']
134
+
135
+ # the input stream.
136
+ rack_env['rack.input'] = stream
137
+
138
+ # the error stream.
139
+ rack_env['rack.errors'] = STDERR
140
+
141
+ # true if the application object may be simultaneously invoked by another thread in the same process, false otherwise.
142
+ rack_env['rack.multithread'] = true
143
+
144
+ # true if an equivalent application object may be simultaneously invoked by another process, false otherwise.
145
+ rack_env['rack.multiprocess'] = false
146
+
147
+ # true if the server expects (but does not guarantee!) that the application will only be invoked this one time during the life of its containing process.
148
+ rack_env['rack.run_once'] = false
149
+
150
+ # present and true if the server supports connection hijacking.
151
+ rack_env['rack.hijack?'] = false
152
+
153
+ rack_env
154
+ end
155
+ end
156
+
157
+ register :alchemy, Rack::Handler::AlchemyFlux
158
+ end
159
+ end