message_bus 3.3.7 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc.js +1 -8
  3. data/.github/workflows/ci.yml +50 -20
  4. data/.prettierrc +1 -0
  5. data/CHANGELOG +99 -46
  6. data/README.md +31 -53
  7. data/Rakefile +22 -22
  8. data/docker-compose.yml +1 -1
  9. data/lib/message_bus/backends/base.rb +14 -0
  10. data/lib/message_bus/backends/memory.rb +13 -0
  11. data/lib/message_bus/backends/postgres.rb +55 -22
  12. data/lib/message_bus/backends/redis.rb +17 -1
  13. data/lib/message_bus/client.rb +26 -22
  14. data/lib/message_bus/distributed_cache.rb +1 -0
  15. data/lib/message_bus/rack/middleware.rb +0 -6
  16. data/lib/message_bus/rack/thin_ext.rb +1 -0
  17. data/lib/message_bus/version.rb +1 -1
  18. data/lib/message_bus.rb +53 -71
  19. data/message_bus.gemspec +4 -3
  20. data/package.json +2 -5
  21. data/spec/helpers.rb +6 -1
  22. data/spec/lib/fake_async_middleware.rb +1 -0
  23. data/spec/lib/message_bus/backend_spec.rb +20 -3
  24. data/spec/lib/message_bus/client_spec.rb +1 -0
  25. data/spec/lib/message_bus/connection_manager_spec.rb +4 -0
  26. data/spec/lib/message_bus/multi_process_spec.rb +21 -10
  27. data/spec/lib/message_bus/rack/middleware_spec.rb +2 -49
  28. data/spec/lib/message_bus/timer_thread_spec.rb +1 -5
  29. data/spec/lib/message_bus_spec.rb +12 -3
  30. data/spec/performance/backlog.rb +80 -0
  31. data/spec/performance/publish.rb +4 -4
  32. data/spec/spec_helper.rb +1 -1
  33. data/vendor/assets/javascripts/message-bus-ajax.js +38 -0
  34. data/vendor/assets/javascripts/message-bus.js +549 -0
  35. metadata +8 -31
  36. data/assets/application.jsx +0 -121
  37. data/assets/babel.min.js +0 -25
  38. data/assets/react-dom.js +0 -19851
  39. data/assets/react.js +0 -3029
  40. data/examples/diagnostics/Gemfile +0 -6
  41. data/examples/diagnostics/config.ru +0 -22
  42. data/lib/message_bus/diagnostics.rb +0 -62
  43. data/lib/message_bus/rack/diagnostics.rb +0 -120
data/Rakefile CHANGED
@@ -5,18 +5,16 @@ require 'bundler'
5
5
  require 'bundler/gem_tasks'
6
6
  require 'bundler/setup'
7
7
  require 'rubocop/rake_task'
8
+ require 'yard'
8
9
 
9
- RuboCop::RakeTask.new
10
+ Bundler.require(:default, :test)
10
11
 
11
- require 'yard'
12
+ RuboCop::RakeTask.new
12
13
  YARD::Rake::YardocTask.new
13
14
 
14
- desc "Generate documentation for Yard, and fail if there are any warnings"
15
- task :test_doc do
16
- sh "yard --fail-on-warning #{'--no-progress' if ENV['CI']}"
17
- end
18
-
19
- Bundler.require(:default, :test)
15
+ BACKENDS = Dir["lib/message_bus/backends/*.rb"].map { |file| file.match(%r{backends/(?<backend>.*).rb})[:backend] } - ["base"]
16
+ SPEC_FILES = Dir['spec/**/*_spec.rb']
17
+ INTEGRATION_FILES = Dir['spec/integration/**/*_spec.rb']
20
18
 
21
19
  module CustomBuild
22
20
  def build_gem
@@ -31,6 +29,11 @@ module Bundler
31
29
  end
32
30
  end
33
31
 
32
+ desc "Generate documentation for Yard, and fail if there are any warnings"
33
+ task :test_doc do
34
+ sh "yard --fail-on-warning #{'--no-progress' if ENV['CI']}"
35
+ end
36
+
34
37
  namespace :jasmine do
35
38
  desc "Run Jasmine tests in headless mode"
36
39
  task 'ci' do
@@ -40,18 +43,16 @@ namespace :jasmine do
40
43
  end
41
44
  end
42
45
 
43
- backends = Dir["lib/message_bus/backends/*.rb"].map { |file| file.match(%r{backends/(?<backend>.*).rb})[:backend] } - ["base"]
44
-
45
46
  namespace :spec do
46
- spec_files = Dir['spec/**/*_spec.rb']
47
- integration_files = Dir['spec/integration/**/*_spec.rb']
48
-
49
- backends.each do |backend|
47
+ BACKENDS.each do |backend|
50
48
  desc "Run tests on the #{backend} backend"
51
49
  task backend do
52
50
  begin
53
51
  ENV['MESSAGE_BUS_BACKEND'] = backend
54
- sh "#{FileUtils::RUBY} -e \"ARGV.each{|f| load f}\" #{(spec_files - integration_files).to_a.join(' ')}"
52
+ Rake::TestTask.new(backend) do |t|
53
+ t.test_files = SPEC_FILES - INTEGRATION_FILES
54
+ end
55
+ Rake::Task[backend].invoke
55
56
  ensure
56
57
  ENV.delete('MESSAGE_BUS_BACKEND')
57
58
  end
@@ -74,7 +75,10 @@ namespace :spec do
74
75
  ENV['MESSAGE_BUS_BACKEND'] = 'memory'
75
76
  pid = spawn("bundle exec puma -p 9292 spec/fixtures/test/config.ru")
76
77
  sleep 1 while port_available?(9292)
77
- sh "#{FileUtils::RUBY} -e \"ARGV.each{|f| load f}\" #{integration_files.to_a.join(' ')}"
78
+ Rake::TestTask.new(:integration) do |t|
79
+ t.test_files = INTEGRATION_FILES
80
+ end
81
+ Rake::Task[:integration].invoke
78
82
  ensure
79
83
  ENV.delete('MESSAGE_BUS_BACKEND')
80
84
  Process.kill('TERM', pid) if pid
@@ -83,12 +87,12 @@ namespace :spec do
83
87
  end
84
88
 
85
89
  desc "Run tests on all backends, plus client JS tests"
86
- task spec: backends.map { |backend| "spec:#{backend}" } + ["jasmine:ci", "spec:integration"]
90
+ task spec: BACKENDS.map { |backend| "spec:#{backend}" } + ["jasmine:ci", "spec:integration"]
87
91
 
88
92
  desc "Run performance benchmarks on all backends"
89
93
  task :performance do
90
94
  begin
91
- ENV['MESSAGE_BUS_BACKENDS'] = backends.join(",")
95
+ ENV['MESSAGE_BUS_BACKENDS'] = BACKENDS.join(",")
92
96
  sh "#{FileUtils::RUBY} -e \"ARGV.each{|f| load f}\" #{Dir['spec/performance/*.rb'].to_a.join(' ')}"
93
97
  ensure
94
98
  ENV.delete('MESSAGE_BUS_BACKENDS')
@@ -97,7 +101,3 @@ end
97
101
 
98
102
  desc "Run all tests, link checks and confirms documentation compiles without error"
99
103
  task default: [:spec, :rubocop, :test_doc]
100
-
101
- Rake::Task['release'].enhance do
102
- sh "yarn publish"
103
- end
data/docker-compose.yml CHANGED
@@ -36,7 +36,7 @@ services:
36
36
  example:
37
37
  build:
38
38
  context: .
39
- command: bash -c "cd examples/diagnostics && bundle install && bundle exec rackup --server puma --host 0.0.0.0"
39
+ command: bash -c "cd examples/chat && bundle install && bundle exec rackup --server puma --host 0.0.0.0"
40
40
  environment:
41
41
  BUNDLE_TO: /usr/local/bundle
42
42
  REDISURL: redis://redis:6379
@@ -84,6 +84,11 @@ module MessageBus
84
84
  raise ConcreteClassMustImplementError
85
85
  end
86
86
 
87
+ # Closes all open connections to the storage.
88
+ def destroy
89
+ raise ConcreteClassMustImplementError
90
+ end
91
+
87
92
  # Deletes all backlogs and their data. Does not delete non-backlog data that message_bus may persist, depending on the concrete backend implementation. Use with extreme caution.
88
93
  # @abstract
89
94
  def expire_all_backlogs!
@@ -113,6 +118,15 @@ module MessageBus
113
118
  raise ConcreteClassMustImplementError
114
119
  end
115
120
 
121
+ # Get the ID of the last message published on multiple channels
122
+ #
123
+ # @param [Array<String>] channels - array of channels to fetch
124
+ #
125
+ # @return [Array<Integer>] the channel-specific IDs of the last message published to each requested channel
126
+ def last_ids(*channels)
127
+ raise ConcreteClassMustImplementError
128
+ end
129
+
116
130
  # Get messages from a channel backlog
117
131
  #
118
132
  # @param [String] channel the name of the channel in question
@@ -212,6 +212,12 @@ module MessageBus
212
212
  client.reset!
213
213
  end
214
214
 
215
+ # No-op; this backend doesn't maintain any storage connections.
216
+ # (see Base#destroy)
217
+ def destroy
218
+ nil
219
+ end
220
+
215
221
  # (see Base#expire_all_backlogs!)
216
222
  def expire_all_backlogs!
217
223
  client.expire_all_backlogs!
@@ -238,6 +244,13 @@ module MessageBus
238
244
  client.max_id(channel)
239
245
  end
240
246
 
247
+ # (see Base#last_ids)
248
+ def last_ids(*channels)
249
+ channels.map do |c|
250
+ last_id(c)
251
+ end
252
+ end
253
+
241
254
  # (see Base#backlog)
242
255
  def backlog(channel, last_id = 0)
243
256
  items = client.backlog channel, last_id.to_i
@@ -45,6 +45,7 @@ module MessageBus
45
45
  @available = []
46
46
  @allocated = {}
47
47
  @subscribe_connection = nil
48
+ @subscribed = false
48
49
  @mutex = Mutex.new
49
50
  @pid = Process.pid
50
51
  end
@@ -86,10 +87,12 @@ module MessageBus
86
87
  hold { |conn| exec_prepared(conn, 'get_message', [channel, id]) { |r| r.getvalue(0, 0) } }
87
88
  end
88
89
 
89
- def reconnect
90
+ def after_fork
90
91
  sync do
91
- @listening_on.clear
92
+ @pid = Process.pid
93
+ INHERITED_CONNECTIONS.concat(@available)
92
94
  @available.clear
95
+ @listening_on.clear
93
96
  end
94
97
  end
95
98
 
@@ -101,6 +104,13 @@ module MessageBus
101
104
  end
102
105
  end
103
106
 
107
+ def destroy
108
+ sync do
109
+ @available.each(&:close)
110
+ @available.clear
111
+ end
112
+ end
113
+
104
114
  # use with extreme care, will nuke all of the data
105
115
  def expire_all_backlogs!
106
116
  reset!
@@ -122,6 +132,21 @@ module MessageBus
122
132
  end
123
133
  end
124
134
 
135
+ def max_ids(*channels)
136
+ block = proc do |pg_result|
137
+ ids = Array.new(channels.size, 0)
138
+ pg_result.ntuples.times do |i|
139
+ channel = pg_result.getvalue(i, 0)
140
+ max_id = pg_result.getvalue(i, 1)
141
+ channel_index = channels.index(channel)
142
+ ids[channel_index] = max_id.to_i
143
+ end
144
+ ids
145
+ end
146
+
147
+ hold { |conn| exec_prepared(conn, 'max_channel_ids', [PG::TextEncoder::Array.new.encode(channels)], &block) }
148
+ end
149
+
125
150
  def publish(channel, data)
126
151
  hold { |conn| exec_prepared(conn, 'publish', [channel, data]) }
127
152
  end
@@ -165,20 +190,23 @@ module MessageBus
165
190
  end
166
191
 
167
192
  def create_table(conn)
168
- conn.exec 'CREATE TABLE message_bus (id bigserial PRIMARY KEY, channel text NOT NULL, value text NOT NULL CHECK (octet_length(value) >= 2), added_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL)'
169
- conn.exec 'CREATE INDEX table_channel_id_index ON message_bus (channel, id)'
170
- conn.exec 'CREATE INDEX table_added_at_index ON message_bus (added_at)'
193
+ sync do
194
+ begin
195
+ conn.exec("SELECT 'message_bus'::regclass")
196
+ rescue PG::UndefinedTable
197
+ conn.exec 'CREATE TABLE message_bus (id bigserial PRIMARY KEY, channel text NOT NULL, value text NOT NULL CHECK (octet_length(value) >= 2), added_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL)'
198
+ conn.exec 'CREATE INDEX table_channel_id_index ON message_bus (channel, id)'
199
+ conn.exec 'CREATE INDEX table_added_at_index ON message_bus (added_at)'
200
+ end
201
+ end
202
+
171
203
  nil
172
204
  end
173
205
 
174
206
  def hold
175
207
  current_pid = Process.pid
176
208
  if current_pid != @pid
177
- @pid = current_pid
178
- sync do
179
- INHERITED_CONNECTIONS.concat(@available)
180
- @available.clear
181
- end
209
+ after_fork
182
210
  end
183
211
 
184
212
  if conn = sync { @allocated[Thread.current] }
@@ -208,11 +236,7 @@ module MessageBus
208
236
  def new_pg_connection
209
237
  conn = raw_pg_connection
210
238
 
211
- begin
212
- conn.exec("SELECT 'message_bus'::regclass")
213
- rescue PG::UndefinedTable
214
- create_table(conn)
215
- end
239
+ create_table(conn)
216
240
 
217
241
  conn.exec 'PREPARE insert_message AS INSERT INTO message_bus (channel, value) VALUES ($1, $2) RETURNING id'
218
242
  conn.exec 'PREPARE clear_global_backlog AS DELETE FROM message_bus WHERE (id <= $1)'
@@ -222,6 +246,7 @@ module MessageBus
222
246
  conn.exec "PREPARE expire AS DELETE FROM message_bus WHERE added_at < CURRENT_TIMESTAMP - ($1::text || ' seconds')::interval"
223
247
  conn.exec 'PREPARE get_message AS SELECT value FROM message_bus WHERE ((channel = $1) AND (id = $2))'
224
248
  conn.exec 'PREPARE max_channel_id AS SELECT max(id) FROM message_bus WHERE (channel = $1)'
249
+ conn.exec 'PREPARE max_channel_ids AS SELECT channel, max(id) FROM message_bus WHERE (channel = ANY($1)) GROUP BY channel'
225
250
  conn.exec 'PREPARE max_id AS SELECT max(id) FROM message_bus'
226
251
  conn.exec 'PREPARE publish AS SELECT pg_notify($1, $2)'
227
252
 
@@ -253,12 +278,14 @@ module MessageBus
253
278
  # after 7 days inactive backlogs will be removed
254
279
  @max_backlog_age = 604800
255
280
  @clear_every = config[:clear_every] || 1
281
+ @mutex = Mutex.new
282
+ @client = nil
256
283
  end
257
284
 
258
285
  # Reconnects to Postgres; used after a process fork, typically triggered by a forking webserver
259
286
  # @see Base#after_fork
260
287
  def after_fork
261
- client.reconnect
288
+ client.after_fork
262
289
  end
263
290
 
264
291
  # (see Base#reset!)
@@ -266,6 +293,11 @@ module MessageBus
266
293
  client.reset!
267
294
  end
268
295
 
296
+ # (see Base#destroy)
297
+ def destroy
298
+ client.destroy
299
+ end
300
+
269
301
  # (see Base#expire_all_backlogs!)
270
302
  def expire_all_backlogs!
271
303
  client.expire_all_backlogs!
@@ -297,7 +329,12 @@ module MessageBus
297
329
  client.max_id(channel)
298
330
  end
299
331
 
300
- # (see Base#last_id)
332
+ # (see Base#last_ids)
333
+ def last_ids(*channels)
334
+ client.max_ids(*channels)
335
+ end
336
+
337
+ # (see Base#backlog)
301
338
  def backlog(channel, last_id = 0)
302
339
  items = client.backlog channel, last_id.to_i
303
340
 
@@ -401,11 +438,7 @@ module MessageBus
401
438
  private
402
439
 
403
440
  def client
404
- @client ||= new_connection
405
- end
406
-
407
- def new_connection
408
- Client.new(@config)
441
+ @client || @mutex.synchronize { @client ||= Client.new(@config) }
409
442
  end
410
443
 
411
444
  def postgresql_channel_name
@@ -58,6 +58,8 @@ module MessageBus
58
58
  @in_memory_backlog = []
59
59
  @lock = Mutex.new
60
60
  @flush_backlog_thread = nil
61
+ @pub_redis = nil
62
+ @subscribed = false
61
63
  # after 7 days inactive backlogs will be removed
62
64
  @max_backlog_age = 604800
63
65
  end
@@ -65,7 +67,7 @@ module MessageBus
65
67
  # Reconnects to Redis; used after a process fork, typically triggered by a forking webserver
66
68
  # @see Base#after_fork
67
69
  def after_fork
68
- pub_redis.disconnect!
70
+ @pub_redis&.disconnect!
69
71
  end
70
72
 
71
73
  # (see Base#reset!)
@@ -75,6 +77,11 @@ module MessageBus
75
77
  end
76
78
  end
77
79
 
80
+ # (see Base#destroy)
81
+ def destroy
82
+ @pub_redis&.disconnect!
83
+ end
84
+
78
85
  # Deletes all backlogs and their data. Does not delete ID pointers, so new publications will get IDs that continue from the last publication before the expiry. Use with extreme caution.
79
86
  # @see Base#expire_all_backlogs!
80
87
  def expire_all_backlogs!
@@ -187,6 +194,13 @@ LUA
187
194
  pub_redis.get(backlog_id_key).to_i
188
195
  end
189
196
 
197
+ # (see Base#last_ids)
198
+ def last_ids(*channels)
199
+ return [] if channels.size == 0
200
+ backlog_id_keys = channels.map { |c| backlog_id_key(c) }
201
+ pub_redis.mget(*backlog_id_keys).map(&:to_i)
202
+ end
203
+
190
204
  # (see Base#backlog)
191
205
  def backlog(channel, last_id = 0)
192
206
  redis = pub_redis
@@ -254,6 +268,7 @@ LUA
254
268
  new_redis.publish(redis_channel_name, UNSUB_MESSAGE)
255
269
  ensure
256
270
  new_redis&.disconnect!
271
+ @subscribed = false
257
272
  end
258
273
  end
259
274
 
@@ -296,6 +311,7 @@ LUA
296
311
 
297
312
  on.message do |_c, m|
298
313
  if m == UNSUB_MESSAGE
314
+ @subscribed = false
299
315
  global_redis.unsubscribe
300
316
  return
301
317
  end
@@ -46,6 +46,9 @@ class MessageBus::Client
46
46
  @bus = opts[:message_bus] || MessageBus
47
47
  @subscriptions = {}
48
48
  @chunks_sent = 0
49
+ @async_response = nil
50
+ @io = nil
51
+ @wrote_headers = false
49
52
  end
50
53
 
51
54
  # @yield executed with a lock on the Client instance
@@ -175,31 +178,32 @@ class MessageBus::Client
175
178
  r = []
176
179
  new_message_ids = nil
177
180
 
178
- @subscriptions.each do |k, v|
179
- id = v.to_i
181
+ last_bus_ids = @bus.last_ids(*@subscriptions.keys, site_id: site_id)
180
182
 
181
- if id < -1
182
- last_id = @bus.last_id(k, site_id)
183
- id = last_id + id + 1
184
- id = 0 if id < 0
183
+ @subscriptions.each do |k, v|
184
+ last_client_id = v.to_i
185
+ last_bus_id = last_bus_ids[k]
186
+
187
+ if last_client_id < -1 # Client requesting backlog relative to bus position
188
+ last_client_id = last_bus_id + last_client_id + 1
189
+ last_client_id = 0 if last_client_id < 0
190
+ elsif last_client_id == -1 # Client not requesting backlog
191
+ next
192
+ elsif last_client_id == last_bus_id # Client already up-to-date
193
+ next
194
+ elsif last_client_id > last_bus_id # Client ahead of the bus
195
+ @subscriptions[k] = -1
196
+ next
185
197
  end
186
198
 
187
- next if id < 0
199
+ messages = @bus.backlog(k, last_client_id, site_id)
188
200
 
189
- messages = @bus.backlog(k, id, site_id)
190
-
191
- if messages.length == 0
192
- if id > @bus.last_id(k, site_id)
193
- @subscriptions[k] = -1
194
- end
195
- else
196
- messages.each do |msg|
197
- if allowed?(msg)
198
- r << msg
199
- else
200
- new_message_ids ||= {}
201
- new_message_ids[k] = msg.message_id
202
- end
201
+ messages.each do |msg|
202
+ if allowed?(msg)
203
+ r << msg
204
+ else
205
+ new_message_ids ||= {}
206
+ new_message_ids[k] = msg.message_id
203
207
  end
204
208
  end
205
209
  end
@@ -209,7 +213,7 @@ class MessageBus::Client
209
213
  @subscriptions.each do |k, v|
210
214
  if v.to_i == -1 || (new_message_ids && new_message_ids[k])
211
215
  status_message ||= {}
212
- @subscriptions[k] = status_message[k] = @bus.last_id(k, site_id)
216
+ @subscriptions[k] = status_message[k] = last_bus_ids[k]
213
217
  end
214
218
  end
215
219
 
@@ -22,6 +22,7 @@ module MessageBus
22
22
  @lock = Mutex.new
23
23
  @message_bus = message_bus || MessageBus
24
24
  @publish_queue_in_memory = publish_queue_in_memory
25
+ @app_version = nil
25
26
  end
26
27
 
27
28
  def subscribers
@@ -41,7 +41,6 @@ class MessageBus::Rack::Middleware
41
41
  @started_listener = false
42
42
  @base_route = "#{@bus.base_route}message-bus/"
43
43
  @base_route_length = @base_route.length
44
- @diagnostics_route = "#{@base_route}_diagnostics"
45
44
  @broadcast_route = "#{@base_route}broadcast"
46
45
  start_listener unless @bus.off?
47
46
  end
@@ -79,11 +78,6 @@ class MessageBus::Rack::Middleware
79
78
  return [200, { "Content-Type" => "text/html" }, ["sent"]]
80
79
  end
81
80
 
82
- if env['PATH_INFO'].start_with? @diagnostics_route
83
- diags = MessageBus::Rack::Diagnostics.new(@app, message_bus: @bus)
84
- return diags.call(env)
85
- end
86
-
87
81
  client_id = env['PATH_INFO'][@base_route_length..-1].split("/")[0]
88
82
  return [404, {}, ["not found"]] unless client_id
89
83
 
@@ -9,6 +9,7 @@ module Thin
9
9
 
10
10
  def initialize
11
11
  @queue = []
12
+ @body_callback = nil
12
13
  end
13
14
 
14
15
  def call(body)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MessageBus
4
- VERSION = "3.3.7"
4
+ VERSION = "4.1.0"
5
5
  end