message_bus 2.2.0.pre.1 → 2.2.0.pre.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of message_bus might be problematic. Click here for more details.

@@ -33,6 +33,19 @@ services:
33
33
  - bundle:/usr/local/bundle
34
34
  ports:
35
35
  - 8808:8808
36
+ example:
37
+ build:
38
+ context: .
39
+ command: bash -c "cd examples/diagnostics && bundle install && bundle exec rackup --server puma --host 0.0.0.0"
40
+ environment:
41
+ BUNDLE_TO: /usr/local/bundle
42
+ REDISURL: redis://redis:6379
43
+ volumes:
44
+ - .:/usr/src/app
45
+ - bundle_config:/home/src/app/.bundle
46
+ - bundle:/usr/local/bundle
47
+ ports:
48
+ - 9292:9292
36
49
  redis:
37
50
  image: redis:5.0
38
51
  volumes:
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'message_bus', path: '../..'
4
+ gem 'redis'
5
+ gem 'puma'
@@ -0,0 +1,21 @@
1
+ require 'message_bus'
2
+
3
+ MessageBus.configure(backend: :redis, url: ENV['REDISURL'])
4
+ MessageBus.enable_diagnostics
5
+
6
+ MessageBus.user_id_lookup do |_env|
7
+ 1
8
+ end
9
+
10
+ MessageBus.is_admin_lookup do |_env|
11
+ true
12
+ end
13
+
14
+ use MessageBus::Rack::Middleware
15
+ run lambda { |_env|
16
+ [
17
+ 200,
18
+ { "Content-Type" => "text/html" },
19
+ ['Howdy. Check out <a href="/message-bus/_diagnostics">the diagnostics UI</a>.']
20
+ ]
21
+ }
@@ -0,0 +1,337 @@
1
+ require 'securerandom'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'uri'
5
+ require 'message_bus/http_client/channel'
6
+
7
+ module MessageBus
8
+ # MessageBus client that enables subscription via long polling with support
9
+ # for chunked encoding. Falls back to normal polling if long polling is not
10
+ # available.
11
+ #
12
+ # @!attribute [r] channels
13
+ # @return [Hash] a map of the channels that the client is subscribed to
14
+ # @!attribute [r] stats
15
+ # @return [Stats] a Struct containing the statistics of failed and successful
16
+ # polling requests
17
+ #
18
+ # @!attribute enable_long_polling
19
+ # @return [Boolean] whether long polling is enabled
20
+ # @!attribute status
21
+ # @return [HTTPClient::STOPPED, HTTPClient::STARTED] the status of the client.
22
+ # @!attribute enable_chunked_encoding
23
+ # @return [Boolean] whether chunked encoding is enabled
24
+ # @!attribute min_poll_interval
25
+ # @return [Float] the min poll interval for long polling
26
+ # @!attribute max_poll_interval
27
+ # @return [Float] the max poll interval for long polling
28
+ # @!attribute background_callback_interval
29
+ # @return [Float] the polling interval
30
+ class HTTPClient
31
+ class InvalidChannel < StandardError; end
32
+ class MissingBlock < StandardError; end
33
+
34
+ attr_reader :channels,
35
+ :stats
36
+
37
+ attr_accessor :enable_long_polling,
38
+ :status,
39
+ :enable_chunked_encoding,
40
+ :min_poll_interval,
41
+ :max_poll_interval,
42
+ :background_callback_interval
43
+
44
+ CHUNK_SEPARATOR = "\r\n|\r\n".freeze
45
+ private_constant :CHUNK_SEPARATOR
46
+ STATUS_CHANNEL = "/__status".freeze
47
+ private_constant :STATUS_CHANNEL
48
+
49
+ STOPPED = 0
50
+ STARTED = 1
51
+
52
+ Stats = Struct.new(:failed, :success)
53
+ private_constant :Stats
54
+
55
+ # @param base_url [String] Base URL of the message_bus server to connect to
56
+ # @param enable_long_polling [Boolean] Enable long polling
57
+ # @param enable_chunked_encoding [Boolean] Enable chunk encoding
58
+ # @param min_poll_interval [Float, Integer] Min poll interval when long polling
59
+ # @param max_poll_interval [Float, Integer] Max poll interval when long polling.
60
+ # When requests fail, the client will backoff and this is the upper limit.
61
+ # @param background_callback_interval [Float, Integer] Interval to poll when
62
+ # when polling.
63
+ # @param headers [Hash] extra HTTP headers to be set on the polling requests.
64
+ #
65
+ # @return [Object] Instance of MessageBus::HTTPClient
66
+ def initialize(base_url, enable_long_polling: true,
67
+ enable_chunked_encoding: true,
68
+ min_poll_interval: 0.1,
69
+ max_poll_interval: 180,
70
+ background_callback_interval: 60,
71
+ headers: {})
72
+
73
+ @uri = URI(base_url)
74
+ @enable_long_polling = enable_long_polling
75
+ @enable_chunked_encoding = enable_chunked_encoding
76
+ @min_poll_interval = min_poll_interval
77
+ @max_poll_interval = max_poll_interval
78
+ @background_callback_interval = background_callback_interval
79
+ @headers = headers
80
+ @client_id = SecureRandom.hex
81
+ @channels = {}
82
+ @status = STOPPED
83
+ @mutex = Mutex.new
84
+ @stats = Stats.new(0, 0)
85
+ end
86
+
87
+ # Starts a background thread that polls the message bus endpoint
88
+ # for the given base_url.
89
+ #
90
+ # Intervals for long polling can be configured via min_poll_interval and
91
+ # max_poll_interval.
92
+ #
93
+ # Intervals for polling can be configured via background_callback_interval.
94
+ #
95
+ # @return [Object] Instance of MessageBus::HTTPClient
96
+ def start
97
+ @mutex.synchronize do
98
+ return if started?
99
+
100
+ @status = STARTED
101
+
102
+ thread = Thread.new do
103
+ begin
104
+ while started?
105
+ unless @channels.empty?
106
+ poll
107
+ @stats.success += 1
108
+ @stats.failed = 0
109
+ end
110
+
111
+ sleep interval
112
+ end
113
+ rescue StandardError => e
114
+ @stats.failed += 1
115
+ warn("#{e.class} #{e.message}: #{e.backtrace.join("\n")}")
116
+ retry
117
+ ensure
118
+ stop
119
+ end
120
+ end
121
+
122
+ thread.abort_on_exception = true
123
+ end
124
+
125
+ self
126
+ end
127
+
128
+ # Stops the client from polling the message bus endpoint.
129
+ #
130
+ # @return [Integer] the current status of the client
131
+ def stop
132
+ @status = STOPPED
133
+ end
134
+
135
+ # Subscribes to a channel which executes the given callback when a message
136
+ # is published to the channel
137
+ #
138
+ # @example Subscribing to a channel for message
139
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
140
+ #
141
+ # client.subscribe("/test") do |payload, _message_id, _global_id|
142
+ # puts payload
143
+ # end
144
+ #
145
+ # A last_message_id may be provided.
146
+ # * -1 will subscribe to all new messages
147
+ # * -2 will recieve last message + all new messages
148
+ # * -3 will recieve last 2 message + all new messages
149
+ #
150
+ # @example Subscribing to a channel with `last_message_id`
151
+ # client.subscribe("/test", last_message_id: -2) do |payload|
152
+ # puts payload
153
+ # end
154
+ #
155
+ # @param channel [String] channel to listen for messages on
156
+ # @param last_message_id [Integer] last message id to start polling on.
157
+ #
158
+ # @yield [data, message_id, global_id]
159
+ # callback to be executed whenever a message is received
160
+ #
161
+ # @yieldparam data [Hash] data payload of the message received on the channel
162
+ # @yieldparam message_id [Integer] id of the message in the channel
163
+ # @yieldparam global_id [Integer] id of the message in the global backlog
164
+ # @yieldreturn [void]
165
+ #
166
+ # @return [Integer] the current status of the client
167
+ def subscribe(channel, last_message_id: nil, &callback)
168
+ raise InvalidChannel unless channel.to_s.start_with?("/")
169
+ raise MissingBlock unless block_given?
170
+
171
+ last_message_id = -1 if last_message_id && !last_message_id.is_a?(Integer)
172
+
173
+ @channels[channel] ||= Channel.new
174
+ channel = @channels[channel]
175
+ channel.last_message_id = last_message_id if last_message_id
176
+ channel.callbacks.push(callback)
177
+ start if stopped?
178
+ end
179
+
180
+ # unsubscribes from a channel
181
+ #
182
+ # @example Unsubscribing from a channel
183
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
184
+ # callback = -> { |payload| puts payload }
185
+ # client.subscribe("/test", &callback)
186
+ # client.unsubscribe("/test")
187
+ #
188
+ # If a callback is given, only the specific callback will be unsubscribed.
189
+ #
190
+ # @example Unsubscribing a callback from a channel
191
+ # client.unsubscribe("/test", &callback)
192
+ #
193
+ # When the client does not have any channels left, it will stop polling and
194
+ # waits until a new subscription is started.
195
+ #
196
+ # @param channel [String] channel to unsubscribe
197
+ # @yield [data, global_id, message_id] specific callback to unsubscribe
198
+ #
199
+ # @return [Integer] the current status of the client
200
+ def unsubscribe(channel, &callback)
201
+ if callback
202
+ @channels[channel].callbacks.delete(callback)
203
+ remove_channel(channel) if @channels[channel].callbacks.empty?
204
+ else
205
+ remove_channel(channel)
206
+ end
207
+
208
+ stop if @channels.empty?
209
+ @status
210
+ end
211
+
212
+ private
213
+
214
+ def stopped?
215
+ @status == STOPPED
216
+ end
217
+
218
+ def started?
219
+ @status == STARTED
220
+ end
221
+
222
+ def remove_channel(channel)
223
+ @channels.delete(channel)
224
+ end
225
+
226
+ def interval
227
+ if @enable_long_polling
228
+ if (failed_count = @stats.failed) > 2
229
+ (@min_poll_interval * failed_count).clamp(
230
+ @min_poll_interval, @max_poll_interval
231
+ )
232
+ else
233
+ @min_poll_interval
234
+ end
235
+ else
236
+ @background_callback_interval
237
+ end
238
+ end
239
+
240
+ def poll
241
+ http = Net::HTTP.new(@uri.host, @uri.port)
242
+ http.use_ssl = true if @uri.scheme == 'https'
243
+ request = Net::HTTP::Post.new(request_path, headers)
244
+ request.body = poll_payload
245
+
246
+ if @enable_long_polling
247
+ buffer = ''
248
+
249
+ http.request(request) do |response|
250
+ response.read_body do |chunk|
251
+ unless chunk.empty?
252
+ buffer << chunk
253
+ process_buffer(buffer)
254
+ end
255
+ end
256
+ end
257
+ else
258
+ response = http.request(request)
259
+ notify_channels(JSON.parse(response.body))
260
+ end
261
+ end
262
+
263
+ def is_chunked?
264
+ !headers["Dont-Chunk"]
265
+ end
266
+
267
+ def process_buffer(buffer)
268
+ index = buffer.index(CHUNK_SEPARATOR)
269
+
270
+ if is_chunked?
271
+ return unless index
272
+
273
+ messages = buffer[0..(index - 1)]
274
+ buffer.slice!("#{messages}#{CHUNK_SEPARATOR}")
275
+ else
276
+ messages = buffer[0..-1]
277
+ buffer.slice!(messages)
278
+ end
279
+
280
+ notify_channels(JSON.parse(messages))
281
+ end
282
+
283
+ def notify_channels(messages)
284
+ messages.each do |message|
285
+ current_channel = message['channel']
286
+
287
+ if current_channel == STATUS_CHANNEL
288
+ message["data"].each do |channel_name, last_message_id|
289
+ if (channel = @channels[channel_name])
290
+ channel.last_message_id = last_message_id
291
+ end
292
+ end
293
+ else
294
+ @channels.each do |channel_name, channel|
295
+ next unless channel_name == current_channel
296
+
297
+ channel.last_message_id = message['message_id']
298
+
299
+ channel.callbacks.each do |callback|
300
+ callback.call(
301
+ message['data'],
302
+ channel.last_message_id,
303
+ message['global_id']
304
+ )
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+
311
+ def poll_payload
312
+ payload = {}
313
+
314
+ @channels.each do |channel_name, channel|
315
+ payload[channel_name] = channel.last_message_id
316
+ end
317
+
318
+ payload.to_json
319
+ end
320
+
321
+ def request_path
322
+ "/message-bus/#{@client_id}/poll"
323
+ end
324
+
325
+ def headers
326
+ headers = {}
327
+ headers['Content-Type'] = 'application/json'
328
+ headers['X-Silence-logger'] = 'true'
329
+
330
+ if !@enable_long_polling || !@enable_chunked_encoding
331
+ headers['Dont-Chunk'] = 'true'
332
+ end
333
+
334
+ headers.merge!(@headers)
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,13 @@
1
+ module MessageBus
2
+ class HTTPClient
3
+ # @private
4
+ class Channel
5
+ attr_accessor :last_message_id, :callbacks
6
+
7
+ def initialize(last_message_id: -1, callbacks: [])
8
+ @last_message_id = last_message_id
9
+ @callbacks = callbacks
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessageBus
4
+ class HTTPClient
5
+ VERSION = '1.0.0.pre1'
6
+ end
7
+ end
@@ -42,10 +42,7 @@ class MessageBus::Rack::Diagnostics
42
42
  if asset && !asset !~ /\//
43
43
  content = asset_contents(asset)
44
44
  split = asset.split('.')
45
- if split[1] == 'handlebars'
46
- content = translate_handlebars(split[0], content)
47
- end
48
- return [200, { 'content-type' => 'text/javascript;' }, [content]]
45
+ return [200, { 'Content-Type' => 'application/javascript;charset=UTF-8' }, [content]]
49
46
  end
50
47
 
51
48
  return [404, {}, ['not found']]
@@ -53,16 +50,16 @@ class MessageBus::Rack::Diagnostics
53
50
 
54
51
  private
55
52
 
56
- def js_asset(name)
57
- return generate_script_tag(name) unless @bus.cache_assets
53
+ def js_asset(name, type = "text/javascript")
54
+ return generate_script_tag(name, type) unless @bus.cache_assets
58
55
 
59
56
  @@asset_cache ||= {}
60
- @@asset_cache[name] ||= generate_script_tag(name)
57
+ @@asset_cache[name] ||= generate_script_tag(name, type)
61
58
  @@asset_cache[name]
62
59
  end
63
60
 
64
- def generate_script_tag(name)
65
- "<script src='/message-bus/_diagnostics/assets/#{name}?#{file_hash(name)}' type='text/javascript'></script>"
61
+ def generate_script_tag(name, type)
62
+ "<script src='/message-bus/_diagnostics/assets/#{name}?#{file_hash(name)}' type='#{type}'></script>"
66
63
  end
67
64
 
68
65
  def file_hash(asset)
@@ -87,24 +84,14 @@ class MessageBus::Rack::Diagnostics
87
84
  <body>
88
85
  <div id="app"></div>
89
86
  #{js_asset "jquery-1.8.2.js"}
90
- #{js_asset "handlebars.js"}
91
- #{js_asset "ember.js"}
87
+ #{js_asset "react.js"}
88
+ #{js_asset "react-dom.js"}
89
+ #{js_asset "babel.min.js"}
92
90
  #{js_asset "message-bus.js"}
93
- #{js_asset "application.handlebars"}
94
- #{js_asset "index.handlebars"}
95
- #{js_asset "application.js"}
91
+ #{js_asset "application.jsx", "text/jsx"}
96
92
  </body>
97
93
  </html>
98
94
  HTML
99
95
  return [200, { "content-type" => "text/html;" }, [html]]
100
96
  end
101
-
102
- def translate_handlebars(name, content)
103
- "Ember.TEMPLATES['#{name}'] = Ember.Handlebars.compile(#{indent(content).inspect});"
104
- end
105
-
106
- # from ember-rails
107
- def indent(string)
108
- string.gsub(/$(.)/m, "\\1 ").strip
109
- end
110
97
  end