alchemy-flux 0.0.1

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,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