message_bus 2.1.6 → 2.2.0.pre

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.

Potentially problematic release.


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

Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -92
  3. data/.rubocop_todo.yml +659 -0
  4. data/.travis.yml +1 -1
  5. data/CHANGELOG +61 -0
  6. data/Dockerfile +18 -0
  7. data/Gemfile +3 -1
  8. data/Guardfile +0 -1
  9. data/README.md +188 -101
  10. data/Rakefile +12 -1
  11. data/assets/message-bus.js +1 -1
  12. data/docker-compose.yml +46 -0
  13. data/examples/bench/config.ru +8 -9
  14. data/examples/bench/unicorn.conf.rb +1 -1
  15. data/examples/chat/chat.rb +150 -153
  16. data/examples/minimal/config.ru +2 -3
  17. data/lib/message_bus.rb +224 -36
  18. data/lib/message_bus/backends.rb +7 -0
  19. data/lib/message_bus/backends/base.rb +184 -0
  20. data/lib/message_bus/backends/memory.rb +304 -226
  21. data/lib/message_bus/backends/postgres.rb +359 -318
  22. data/lib/message_bus/backends/redis.rb +380 -337
  23. data/lib/message_bus/client.rb +99 -41
  24. data/lib/message_bus/connection_manager.rb +29 -21
  25. data/lib/message_bus/diagnostics.rb +50 -41
  26. data/lib/message_bus/distributed_cache.rb +5 -7
  27. data/lib/message_bus/message.rb +2 -2
  28. data/lib/message_bus/rack/diagnostics.rb +65 -55
  29. data/lib/message_bus/rack/middleware.rb +64 -44
  30. data/lib/message_bus/rack/thin_ext.rb +13 -9
  31. data/lib/message_bus/rails/railtie.rb +2 -0
  32. data/lib/message_bus/timer_thread.rb +2 -2
  33. data/lib/message_bus/version.rb +2 -1
  34. data/message_bus.gemspec +3 -2
  35. data/spec/assets/support/jasmine_helper.rb +1 -1
  36. data/spec/lib/fake_async_middleware.rb +1 -6
  37. data/spec/lib/message_bus/assets/asset_encoding_spec.rb +3 -3
  38. data/spec/lib/message_bus/backend_spec.rb +409 -0
  39. data/spec/lib/message_bus/client_spec.rb +8 -11
  40. data/spec/lib/message_bus/connection_manager_spec.rb +8 -14
  41. data/spec/lib/message_bus/distributed_cache_spec.rb +0 -4
  42. data/spec/lib/message_bus/multi_process_spec.rb +6 -7
  43. data/spec/lib/message_bus/rack/middleware_spec.rb +47 -43
  44. data/spec/lib/message_bus/timer_thread_spec.rb +0 -2
  45. data/spec/lib/message_bus_spec.rb +59 -43
  46. data/spec/spec_helper.rb +16 -4
  47. metadata +12 -9
  48. data/spec/lib/message_bus/backends/postgres_spec.rb +0 -221
  49. data/spec/lib/message_bus/backends/redis_spec.rb +0 -271
@@ -1,9 +1,40 @@
1
1
  # frozen_string_literal: true
2
- class MessageBus::Client
3
- attr_accessor :client_id, :user_id, :group_ids, :connect_time,
4
- :subscribed_sets, :site_id, :cleanup_timer,
5
- :async_response, :io, :headers, :seq, :use_chunked
6
2
 
3
+ # Represents a connected subscriber and delivers published messages over its
4
+ # connected socket.
5
+ class MessageBus::Client
6
+ # @return [String] the unique ID provided by the client
7
+ attr_accessor :client_id
8
+ # @return [String,Integer] the user ID the client was authenticated for
9
+ attr_accessor :user_id
10
+ # @return [Array<String,Integer>] the group IDs the authenticated client is a member of
11
+ attr_accessor :group_ids
12
+ # @return [Time] the time at which the client connected
13
+ attr_accessor :connect_time
14
+ # @return [String] the site ID the client was authenticated for; used for hosting multiple
15
+ attr_accessor :site_id
16
+ # @return [MessageBus::TimerThread::Cancelable] a timer job that is used to
17
+ # auto-disconnect the client at the configured long-polling interval
18
+ attr_accessor :cleanup_timer
19
+ # @return [Thin::AsyncResponse, nil]
20
+ attr_accessor :async_response
21
+ # @return [IO] the HTTP socket the client is connected on
22
+ attr_accessor :io
23
+ # @return [Hash<String => String>] custom headers to include in HTTP responses
24
+ attr_accessor :headers
25
+ # @return [Integer] the connection sequence number the client provided when connecting
26
+ attr_accessor :seq
27
+ # @return [Boolean] whether or not the client should use chunked encoding
28
+ attr_accessor :use_chunked
29
+
30
+ # @param [Hash] opts
31
+ # @option opts [String] :client_id the unique ID provided by the client
32
+ # @option opts [String,Integer] :user_id (`nil`) the user ID the client was authenticated for
33
+ # @option opts [Array<String,Integer>] :group_ids (`[]`) the group IDs the authenticated client is a member of
34
+ # @option opts [String] :site_id (`nil`) the site ID the client was authenticated for; used for hosting multiple
35
+ # applications or instances of an application against a single message_bus
36
+ # @option opts [#to_i] :seq (`0`) the connection sequence number the client provided when connecting
37
+ # @option opts [MessageBus::Instance] :message_bus (`MessageBus`) a specific instance of message_bus
7
38
  def initialize(opts)
8
39
  self.client_id = opts[:client_id]
9
40
  self.user_id = opts[:user_id]
@@ -17,11 +48,14 @@ class MessageBus::Client
17
48
  @chunks_sent = 0
18
49
  end
19
50
 
51
+ # @yield executed with a lock on the Client instance
52
+ # @return [void]
20
53
  def synchronize
21
54
  @lock.synchronize { yield }
22
55
  end
23
56
 
24
- def cancel
57
+ # Closes the client connection
58
+ def close
25
59
  if cleanup_timer
26
60
  # concurrency may nil cleanup timer
27
61
  cleanup_timer.cancel rescue nil
@@ -30,10 +64,12 @@ class MessageBus::Client
30
64
  ensure_closed!
31
65
  end
32
66
 
33
- def in_async?
34
- @async_response || @io
35
- end
36
-
67
+ # Delivers a backlog of messages to the client, if there is anything in it.
68
+ # If chunked encoding/streaming is in use, will keep the connection open;
69
+ # if not, will close it.
70
+ #
71
+ # @param [Array<MessageBus::Message>] backlog the set of messages to deliver
72
+ # @return [void]
37
73
  def deliver_backlog(backlog)
38
74
  if backlog.length > 0
39
75
  if use_chunked
@@ -44,53 +80,41 @@ class MessageBus::Client
44
80
  end
45
81
  end
46
82
 
83
+ # If no data has yet been sent to the client, sends an empty chunk; prevents
84
+ # clients from entering a timeout state if nothing is delivered initially.
47
85
  def ensure_first_chunk_sent
48
86
  if use_chunked && @chunks_sent == 0
49
87
  write_chunk("[]")
50
88
  end
51
89
  end
52
90
 
53
- def ensure_closed!
54
- return unless in_async?
55
- if use_chunked
56
- write_chunk("[]")
57
- if @io
58
- @io.write("0\r\n\r\n")
59
- @io.close
60
- @io = nil
61
- end
62
- if @async_response
63
- @async_response << ("0\r\n\r\n")
64
- @async_response.done
65
- @async_response = nil
66
- end
67
- else
68
- write_and_close "[]"
69
- end
70
- rescue
71
- # we may have a dead socket, just nil the @io
72
- @io = nil
73
- @async_response = nil
74
- end
75
-
76
- def close
77
- ensure_closed!
78
- end
79
-
91
+ # @return [Boolean] whether the connection is closed or not
80
92
  def closed?
81
93
  !@async_response && !@io
82
94
  end
83
95
 
96
+ # Subscribes the client to messages on a channel, optionally from a
97
+ # defined starting point.
98
+ #
99
+ # @param [String] channel the channel to subscribe to
100
+ # @param [Integer, nil] last_seen_id the ID of the last message the client
101
+ # received. If nil, will be subscribed from the head of the backlog.
102
+ # @return [void]
84
103
  def subscribe(channel, last_seen_id)
85
104
  last_seen_id = nil if last_seen_id == ""
86
105
  last_seen_id ||= @bus.last_id(channel)
87
106
  @subscriptions[channel] = last_seen_id.to_i
88
107
  end
89
108
 
109
+ # @return [Hash<String => Integer>] the active subscriptions, mapping channel
110
+ # names to last seen message IDs
90
111
  def subscriptions
91
112
  @subscriptions
92
113
  end
93
114
 
115
+ # Delivers a message to the client, even if it's empty
116
+ # @param [MessageBus::Message, nil] msg the message to deliver
117
+ # @return [void]
94
118
  def <<(msg)
95
119
  json = messages_to_json([msg])
96
120
  if use_chunked
@@ -100,10 +124,9 @@ class MessageBus::Client
100
124
  end
101
125
  end
102
126
 
103
- def subscriptions
104
- @subscriptions
105
- end
106
-
127
+ # @param [MessageBus::Message] msg the message in question
128
+ # @return [Boolean] whether or not the client has permission to receive the
129
+ # passed message
107
130
  def allowed?(msg)
108
131
  allowed = !msg.user_ids || msg.user_ids.include?(self.user_id)
109
132
  allowed &&= !msg.client_ids || msg.client_ids.include?(self.client_id)
@@ -116,6 +139,11 @@ class MessageBus::Client
116
139
  )
117
140
  end
118
141
 
142
+ # @return [Array<MessageBus::Message>] the set of messages the client is due
143
+ # to receive, based on its subscriptions and permissions. Includes status
144
+ # message if any channels have no messages available and the client
145
+ # requested a message newer than the newest on the channel, or when there
146
+ # are messages available that the client doesn't have permission for.
119
147
  def backlog
120
148
  r = []
121
149
  new_message_ids = nil
@@ -130,6 +158,7 @@ class MessageBus::Client
130
158
  end
131
159
 
132
160
  next if id < 0
161
+
133
162
  messages = @bus.backlog(k, id, site_id)
134
163
 
135
164
  if messages.length == 0
@@ -162,7 +191,7 @@ class MessageBus::Client
162
191
  r || []
163
192
  end
164
193
 
165
- protected
194
+ private
166
195
 
167
196
  # heavily optimised to avoid all uneeded allocations
168
197
  NEWLINE = "\r\n".freeze
@@ -180,6 +209,7 @@ class MessageBus::Client
180
209
  @io.write(HTTP_11)
181
210
  @headers.each do |k, v|
182
211
  next if k == "Content-Type"
212
+
183
213
  @io.write(k)
184
214
  @io.write(COLON_SPACE)
185
215
  @io.write(v)
@@ -244,7 +274,35 @@ class MessageBus::Client
244
274
  end
245
275
  end
246
276
 
277
+ def ensure_closed!
278
+ return unless in_async?
279
+
280
+ if use_chunked
281
+ write_chunk("[]")
282
+ if @io
283
+ @io.write("0\r\n\r\n")
284
+ @io.close
285
+ @io = nil
286
+ end
287
+ if @async_response
288
+ @async_response << ("0\r\n\r\n")
289
+ @async_response.done
290
+ @async_response = nil
291
+ end
292
+ else
293
+ write_and_close "[]"
294
+ end
295
+ rescue
296
+ # we may have a dead socket, just nil the @io
297
+ @io = nil
298
+ @async_response = nil
299
+ end
300
+
247
301
  def messages_to_json(msgs)
248
302
  MessageBus::Rack::Middleware.backlog_to_json(msgs)
249
303
  end
304
+
305
+ def in_async?
306
+ @async_response || @io
307
+ end
250
308
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'json' unless defined? ::JSON
3
4
 
5
+ # Manages a set of subscribers with active connections to the server, such that
6
+ # messages which are published during the connection may be dispatched.
4
7
  class MessageBus::ConnectionManager
5
8
  require 'monitor'
6
9
  include MonitorMixin
7
10
 
11
+ # @param [MessageBus::Instance] bus the message bus for which to manage connections
8
12
  def initialize(bus = nil)
9
13
  @clients = {}
10
14
  @subscriptions = {}
@@ -12,6 +16,9 @@ class MessageBus::ConnectionManager
12
16
  mon_initialize
13
17
  end
14
18
 
19
+ # Dispatches a message to any connected clients which are permitted to receive it
20
+ # @param [MessageBus::Message] msg the message to dispatch
21
+ # @return [void]
15
22
  def notify_clients(msg)
16
23
  synchronize do
17
24
  begin
@@ -34,37 +41,42 @@ class MessageBus::ConnectionManager
34
41
  remove_client(client) if client.closed?
35
42
  end
36
43
  end
37
-
38
44
  rescue => e
39
45
  @bus.logger.error "notify clients crash #{e} : #{e.backtrace}"
40
46
  end
41
47
  end
42
48
  end
43
49
 
50
+ # Keeps track of a client with an active connection
51
+ # @param [MessageBus::Client] client the client to track
52
+ # @return [void]
44
53
  def add_client(client)
45
54
  synchronize do
46
55
  existing = @clients[client.client_id]
47
56
  if existing && existing.seq > client.seq
48
- client.cancel
57
+ client.close
49
58
  else
50
59
  if existing
51
60
  remove_client(existing)
52
- existing.cancel
61
+ existing.close
53
62
  end
54
63
 
55
64
  @clients[client.client_id] = client
56
65
  @subscriptions[client.site_id] ||= {}
57
- client.subscriptions.each do |k, v|
66
+ client.subscriptions.each do |k, _v|
58
67
  subscribe_client(client, k)
59
68
  end
60
69
  end
61
70
  end
62
71
  end
63
72
 
73
+ # Removes a client
74
+ # @param [MessageBus::Client] c the client to remove
75
+ # @return [void]
64
76
  def remove_client(c)
65
77
  synchronize do
66
78
  @clients.delete c.client_id
67
- @subscriptions[c.site_id].each do |k, set|
79
+ @subscriptions[c.site_id].each do |_k, set|
68
80
  set.delete c.client_id
69
81
  end
70
82
  if c.cleanup_timer
@@ -74,12 +86,24 @@ class MessageBus::ConnectionManager
74
86
  end
75
87
  end
76
88
 
89
+ # Finds a client by ID
90
+ # @param [String] client_id the client ID to search by
91
+ # @return [MessageBus::Client] the client with the specified ID
77
92
  def lookup_client(client_id)
78
93
  synchronize do
79
94
  @clients[client_id]
80
95
  end
81
96
  end
82
97
 
98
+ # @return [Integer] the number of tracked clients
99
+ def client_count
100
+ synchronize do
101
+ @clients.length
102
+ end
103
+ end
104
+
105
+ private
106
+
83
107
  def subscribe_client(client, channel)
84
108
  synchronize do
85
109
  set = @subscriptions[client.site_id][channel]
@@ -90,20 +114,4 @@ class MessageBus::ConnectionManager
90
114
  set << client.client_id
91
115
  end
92
116
  end
93
-
94
- def client_count
95
- synchronize do
96
- @clients.length
97
- end
98
- end
99
-
100
- def stats
101
- synchronize do
102
- {
103
- client_count: @clients.length,
104
- subscriptions: @subscriptions
105
- }
106
- end
107
- end
108
-
109
117
  end
@@ -1,53 +1,62 @@
1
+ # MessageBus diagnostics are used for troubleshooting the bus and optimising its configuration
2
+ # @see MessageBus::Rack::Diagnostics
1
3
  class MessageBus::Diagnostics
2
- def self.full_process_path
3
- begin
4
- system = `uname`.strip
5
- if system == "Darwin"
6
- `ps -o "comm=" -p #{Process.pid}`
7
- elsif system == "FreeBSD"
8
- `ps -o command -p #{Process.pid}`.split("\n",2)[1].strip()
9
- else
10
- info = `ps -eo "%p|$|%a" | grep '^\\s*#{Process.pid}'`
11
- info.strip.split('|$|')[1]
4
+ class << self
5
+ # Enables diagnostics functionality
6
+ # @param [MessageBus::Instance] bus a specific instance of message_bus
7
+ # @return [void]
8
+ def enable(bus = MessageBus)
9
+ full_path = full_process_path
10
+ start_time = Time.now.to_f
11
+ hostname = self.hostname
12
+
13
+ # it may make sense to add a channel per machine/host to streamline
14
+ # process to process comms
15
+ bus.subscribe('/_diagnostics/hup') do |msg|
16
+ if Process.pid == msg.data["pid"] && hostname == msg.data["hostname"]
17
+ $shutdown = true
18
+ sleep 4
19
+ Process.kill("HUP", $$)
20
+ end
12
21
  end
13
- rescue
14
- # skip it ... not linux or something weird
15
- end
16
- end
17
22
 
18
- def self.hostname
19
- begin
20
- `hostname`.strip
21
- rescue
22
- # skip it
23
+ bus.subscribe('/_diagnostics/discover') do |msg|
24
+ bus.on_connect.call msg.site_id if bus.on_connect
25
+ bus.publish '/_diagnostics/process-discovery', {
26
+ pid: Process.pid,
27
+ process_name: $0,
28
+ full_path: full_path,
29
+ uptime: (Time.now.to_f - start_time).to_i,
30
+ hostname: hostname
31
+ }, user_ids: [msg.data["user_id"]]
32
+ bus.on_disconnect.call msg.site_id if bus.on_disconnect
33
+ end
23
34
  end
24
- end
25
35
 
26
- def self.enable(bus = MessageBus)
27
- full_path = full_process_path
28
- start_time = Time.now.to_f
29
- hostname = self.hostname
36
+ private
30
37
 
31
- # it may make sense to add a channel per machine/host to streamline
32
- # process to process comms
33
- bus.subscribe('/_diagnostics/hup') do |msg|
34
- if Process.pid == msg.data["pid"] && hostname == msg.data["hostname"]
35
- $shutdown = true
36
- sleep 4
37
- Process.kill("HUP", $$)
38
+ def full_process_path
39
+ begin
40
+ system = `uname`.strip
41
+ if system == "Darwin"
42
+ `ps -o "comm=" -p #{Process.pid}`
43
+ elsif system == "FreeBSD"
44
+ `ps -o command -p #{Process.pid}`.split("\n", 2)[1].strip
45
+ else
46
+ info = `ps -eo "%p|$|%a" | grep '^\\s*#{Process.pid}'`
47
+ info.strip.split('|$|')[1]
48
+ end
49
+ rescue
50
+ # skip it ... not linux or something weird
38
51
  end
39
52
  end
40
53
 
41
- bus.subscribe('/_diagnostics/discover') do |msg|
42
- bus.on_connect.call msg.site_id if bus.on_connect
43
- bus.publish '/_diagnostics/process-discovery', {
44
- pid: Process.pid,
45
- process_name: $0,
46
- full_path: full_path,
47
- uptime: (Time.now.to_f - start_time).to_i,
48
- hostname: hostname
49
- }, user_ids: [msg.data["user_id"]]
50
- bus.on_disconnect.call msg.site_id if bus.on_disconnect
54
+ def hostname
55
+ begin
56
+ `hostname`.strip
57
+ rescue
58
+ # skip it
59
+ end
51
60
  end
52
61
  end
53
62
  end
@@ -1,16 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Like a hash, just does its best to stay in sync across the farm.
4
- # On boot all instances are blank, but they populate as various processes
5
- # fill it up.
6
-
7
3
  require 'weakref'
8
4
  require 'base64'
9
5
  require 'securerandom'
10
6
 
11
7
  module MessageBus
8
+ # Like a hash, just does its best to stay in sync across the farm.
9
+ # On boot all instances are blank, but they populate as various processes
10
+ # fill it up.
12
11
  class DistributedCache
13
-
14
12
  DEFAULT_SITE_ID = 'default'
15
13
 
16
14
  class Manager
@@ -50,7 +48,6 @@ module MessageBus
50
48
  when "delete" then hash.delete(payload["key"])
51
49
  when "clear" then hash.clear
52
50
  end
53
-
54
51
  rescue WeakRef::RefError
55
52
  @subscribers.delete_at(i)
56
53
  ensure
@@ -61,8 +58,10 @@ module MessageBus
61
58
 
62
59
  def ensure_subscribe!
63
60
  return if @subscribed
61
+
64
62
  @lock.synchronize do
65
63
  return if @subscribed
64
+
66
65
  @message_bus.subscribe(CHANNEL_NAME) do |message|
67
66
  @lock.synchronize do
68
67
  process_message(message)
@@ -160,6 +159,5 @@ module MessageBus
160
159
 
161
160
  @data[site_id] ||= {}
162
161
  end
163
-
164
162
  end
165
163
  end