message_bus 2.1.6 → 2.2.0.pre

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.

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