message_bus 2.0.0.beta.2 → 2.0.0.beta.3
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.
- checksums.yaml +4 -4
- data/.travis.yml +10 -2
- data/CHANGELOG +10 -0
- data/Gemfile +1 -6
- data/README.md +43 -16
- data/Rakefile +18 -4
- data/lib/message_bus.rb +64 -47
- data/lib/message_bus/backends/postgres.rb +396 -0
- data/lib/message_bus/{redis/reliable_pub_sub.rb → backends/redis.rb} +1 -0
- data/lib/message_bus/rack/middleware.rb +14 -5
- data/lib/message_bus/version.rb +1 -1
- data/message_bus.gemspec +3 -1
- data/spec/lib/message_bus/assets/asset_encoding_spec.rb +4 -4
- data/spec/lib/message_bus/backends/postgres_spec.rb +208 -0
- data/spec/lib/message_bus/{redis/reliable_pub_sub_spec.rb → backends/redis_spec.rb} +25 -23
- data/spec/lib/message_bus/client_spec.rb +28 -27
- data/spec/lib/message_bus/connection_manager_spec.rb +22 -24
- data/spec/lib/message_bus/multi_process_spec.rb +54 -27
- data/spec/lib/message_bus/rack/middleware_spec.rb +81 -38
- data/spec/lib/message_bus/timer_thread_spec.rb +6 -6
- data/spec/lib/message_bus_spec.rb +36 -35
- data/spec/spec_helper.rb +16 -21
- metadata +24 -7
@@ -0,0 +1,396 @@
|
|
1
|
+
require 'pg'
|
2
|
+
|
3
|
+
module MessageBus::Postgres; end
|
4
|
+
|
5
|
+
class MessageBus::Postgres::Client
|
6
|
+
INHERITED_CONNECTIONS = []
|
7
|
+
|
8
|
+
class Listener
|
9
|
+
attr_reader :do_sub, :do_unsub, :do_message
|
10
|
+
|
11
|
+
def subscribe(&block)
|
12
|
+
@do_sub = block
|
13
|
+
end
|
14
|
+
|
15
|
+
def unsubscribe(&block)
|
16
|
+
@do_unsub = block
|
17
|
+
end
|
18
|
+
|
19
|
+
def message(&block)
|
20
|
+
@do_message = block
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(config)
|
25
|
+
@config = config
|
26
|
+
@listening_on = {}
|
27
|
+
@available = []
|
28
|
+
@allocated = {}
|
29
|
+
@mutex = Mutex.new
|
30
|
+
@pid = Process.pid
|
31
|
+
end
|
32
|
+
|
33
|
+
def add(channel, value)
|
34
|
+
hold{|conn| exec_prepared(conn, 'insert_message', [channel, value]){|r| r.getvalue(0,0).to_i}}
|
35
|
+
end
|
36
|
+
|
37
|
+
def clear_global_backlog(backlog_id, num_to_keep)
|
38
|
+
if backlog_id > num_to_keep
|
39
|
+
hold{|conn| exec_prepared(conn, 'clear_global_backlog', [backlog_id - num_to_keep])}
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def clear_channel_backlog(channel, backlog_id, num_to_keep)
|
45
|
+
hold{|conn| exec_prepared(conn, 'clear_channel_backlog', [channel, backlog_id, num_to_keep])}
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
|
49
|
+
def expire(max_backlog_age)
|
50
|
+
hold{|conn| exec_prepared(conn, 'expire', [max_backlog_age])}
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
|
54
|
+
def backlog(channel, backlog_id)
|
55
|
+
hold{|conn| exec_prepared(conn, 'channel_backlog', [channel, backlog_id]){|r| r.values.each{|a| a[0] = a[0].to_i}}}
|
56
|
+
end
|
57
|
+
|
58
|
+
def global_backlog(backlog_id)
|
59
|
+
hold{|conn| exec_prepared(conn, 'global_backlog', [backlog_id]){|r| r.values.each{|a| a[0] = a[0].to_i}}}
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_value(channel, id)
|
63
|
+
hold{|conn| exec_prepared(conn, 'get_message', [channel, id]){|r| r.getvalue(0,0)}}
|
64
|
+
end
|
65
|
+
|
66
|
+
def reconnect
|
67
|
+
sync do
|
68
|
+
@listening_on.clear
|
69
|
+
@available.clear
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Dangerous, drops the message_bus table containing the backlog if it exists.
|
74
|
+
def reset!
|
75
|
+
hold do |conn|
|
76
|
+
conn.exec 'DROP TABLE IF EXISTS message_bus'
|
77
|
+
create_table(conn)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def max_id(channel=nil)
|
82
|
+
block = proc do |r|
|
83
|
+
if r.ntuples > 0
|
84
|
+
r.getvalue(0,0).to_i
|
85
|
+
else
|
86
|
+
0
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
if channel
|
91
|
+
hold{|conn| exec_prepared(conn, 'max_channel_id', [channel], &block)}
|
92
|
+
else
|
93
|
+
hold{|conn| exec_prepared(conn, 'max_id', &block)}
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def publish(channel, data)
|
98
|
+
hold{|conn| exec_prepared(conn, 'publish', [channel, data])}
|
99
|
+
end
|
100
|
+
|
101
|
+
def subscribe(channel)
|
102
|
+
obj = Object.new
|
103
|
+
sync{@listening_on[channel] = obj}
|
104
|
+
listener = Listener.new
|
105
|
+
yield listener
|
106
|
+
pid = Process.pid
|
107
|
+
|
108
|
+
conn = raw_pg_connection
|
109
|
+
conn.exec "LISTEN #{channel}"
|
110
|
+
listener.do_sub.call
|
111
|
+
while listening_on?(channel, obj)
|
112
|
+
conn.wait_for_notify(10) do |_,_,payload|
|
113
|
+
listener.do_message.call(nil, payload)
|
114
|
+
end
|
115
|
+
break if pid != Process.pid
|
116
|
+
end
|
117
|
+
listener.do_unsub.call
|
118
|
+
|
119
|
+
if pid != Process.pid
|
120
|
+
sync{INHERITED_CONNECTIONS << conn}
|
121
|
+
else
|
122
|
+
conn.exec "UNLISTEN #{channel}"
|
123
|
+
end
|
124
|
+
|
125
|
+
nil
|
126
|
+
end
|
127
|
+
|
128
|
+
def unsubscribe
|
129
|
+
sync{@listening_on.clear}
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def exec_prepared(conn, *a)
|
135
|
+
r = conn.exec_prepared(*a)
|
136
|
+
yield r if block_given?
|
137
|
+
ensure
|
138
|
+
r.clear if r.respond_to?(:clear)
|
139
|
+
end
|
140
|
+
|
141
|
+
def create_table(conn)
|
142
|
+
conn.exec 'CREATE TABLE message_bus (id bigserial PRIMARY KEY, channel text NOT NULL, value text NOT NULL, added_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL)'
|
143
|
+
conn.exec 'CREATE INDEX table_channel_id_index ON message_bus (channel, id)'
|
144
|
+
conn.exec 'CREATE INDEX table_added_at_index ON message_bus (added_at)'
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
|
148
|
+
def hold
|
149
|
+
current_pid = Process.pid
|
150
|
+
if current_pid != @pid
|
151
|
+
@pid = current_pid
|
152
|
+
sync do
|
153
|
+
@listening_on.clear
|
154
|
+
INHERITED_CONNECTIONS.concat(@available)
|
155
|
+
@available.clear
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
if conn = sync{@allocated[Thread.current]}
|
160
|
+
return yield(conn)
|
161
|
+
end
|
162
|
+
|
163
|
+
begin
|
164
|
+
conn = sync{@available.shift} || new_pg_connection
|
165
|
+
sync{@allocated[Thread.current] = conn}
|
166
|
+
yield conn
|
167
|
+
rescue PG::ConnectionBad => e
|
168
|
+
# don't add this connection back to the pool
|
169
|
+
ensure
|
170
|
+
sync{@allocated.delete(Thread.current)}
|
171
|
+
if Process.pid != current_pid
|
172
|
+
sync{INHERITED_CONNECTIONS << conn}
|
173
|
+
elsif conn && !e
|
174
|
+
sync{@available << conn}
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def raw_pg_connection
|
180
|
+
PG::Connection.connect(@config[:backend_options] || {})
|
181
|
+
end
|
182
|
+
|
183
|
+
def new_pg_connection
|
184
|
+
conn = raw_pg_connection
|
185
|
+
|
186
|
+
begin
|
187
|
+
conn.exec("SELECT 'message_bus'::regclass")
|
188
|
+
rescue PG::UndefinedTable
|
189
|
+
create_table(conn)
|
190
|
+
end
|
191
|
+
|
192
|
+
conn.exec 'PREPARE insert_message AS INSERT INTO message_bus (channel, value) VALUES ($1, $2) RETURNING id'
|
193
|
+
conn.exec 'PREPARE clear_global_backlog AS DELETE FROM message_bus WHERE (id <= $1)'
|
194
|
+
conn.exec 'PREPARE clear_channel_backlog AS DELETE FROM message_bus WHERE ((channel = $1) AND (id <= (SELECT id FROM message_bus WHERE ((channel = $1) AND (id <= $2)) ORDER BY id DESC OFFSET $3)))'
|
195
|
+
conn.exec 'PREPARE channel_backlog AS SELECT id, value FROM message_bus WHERE ((channel = $1) AND (id > $2)) ORDER BY id'
|
196
|
+
conn.exec 'PREPARE global_backlog AS SELECT id, channel, value FROM message_bus WHERE (id > $1) ORDER BY id'
|
197
|
+
conn.exec "PREPARE expire AS DELETE FROM message_bus WHERE added_at < CURRENT_TIMESTAMP - ($1::text || ' seconds')::interval"
|
198
|
+
conn.exec 'PREPARE get_message AS SELECT value FROM message_bus WHERE ((channel = $1) AND (id = $2))'
|
199
|
+
conn.exec 'PREPARE max_channel_id AS SELECT max(id) FROM message_bus WHERE (channel = $1)'
|
200
|
+
conn.exec 'PREPARE max_id AS SELECT max(id) FROM message_bus'
|
201
|
+
conn.exec 'PREPARE publish AS SELECT pg_notify($1, $2)'
|
202
|
+
|
203
|
+
conn
|
204
|
+
end
|
205
|
+
|
206
|
+
def listening_on?(channel, obj)
|
207
|
+
sync{@listening_on[channel]} == obj
|
208
|
+
end
|
209
|
+
|
210
|
+
def sync
|
211
|
+
@mutex.synchronize{yield}
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
class MessageBus::Postgres::ReliablePubSub
|
216
|
+
attr_reader :subscribed
|
217
|
+
attr_accessor :max_publish_retries, :max_publish_wait, :max_backlog_size,
|
218
|
+
:max_global_backlog_size, :max_in_memory_publish_backlog,
|
219
|
+
:max_backlog_age
|
220
|
+
|
221
|
+
UNSUB_MESSAGE = "$$UNSUBSCRIBE"
|
222
|
+
|
223
|
+
def self.reset!(config)
|
224
|
+
MessageBus::Postgres::Client.new(config).reset!
|
225
|
+
end
|
226
|
+
|
227
|
+
# max_backlog_size is per multiplexed channel
|
228
|
+
def initialize(config = {}, max_backlog_size = 1000)
|
229
|
+
@config = config
|
230
|
+
@max_backlog_size = max_backlog_size
|
231
|
+
@max_global_backlog_size = 2000
|
232
|
+
@max_publish_retries = 10
|
233
|
+
@max_publish_wait = 500 #ms
|
234
|
+
@max_in_memory_publish_backlog = 1000
|
235
|
+
@in_memory_backlog = []
|
236
|
+
@lock = Mutex.new
|
237
|
+
@flush_backlog_thread = nil
|
238
|
+
# after 7 days inactive backlogs will be removed
|
239
|
+
@max_backlog_age = 604800
|
240
|
+
@h = {}
|
241
|
+
end
|
242
|
+
|
243
|
+
def new_connection
|
244
|
+
MessageBus::Postgres::Client.new(@config)
|
245
|
+
end
|
246
|
+
|
247
|
+
def backend
|
248
|
+
:postgres
|
249
|
+
end
|
250
|
+
|
251
|
+
def after_fork
|
252
|
+
client.reconnect
|
253
|
+
end
|
254
|
+
|
255
|
+
def postgresql_channel_name
|
256
|
+
db = @config[:db] || 0
|
257
|
+
"_message_bus_#{db}"
|
258
|
+
end
|
259
|
+
|
260
|
+
def client
|
261
|
+
@client ||= new_connection
|
262
|
+
end
|
263
|
+
|
264
|
+
# use with extreme care, will nuke all of the data
|
265
|
+
def reset!
|
266
|
+
client.reset!
|
267
|
+
end
|
268
|
+
|
269
|
+
def publish(channel, data, queue_in_memory=true)
|
270
|
+
client = self.client
|
271
|
+
backlog_id = client.add(channel, data)
|
272
|
+
msg = MessageBus::Message.new backlog_id, backlog_id, channel, data
|
273
|
+
payload = msg.encode
|
274
|
+
client.publish postgresql_channel_name, payload
|
275
|
+
client.clear_global_backlog(backlog_id, @max_global_backlog_size)
|
276
|
+
client.expire(@max_backlog_age)
|
277
|
+
client.clear_channel_backlog(channel, backlog_id, @max_backlog_size)
|
278
|
+
|
279
|
+
backlog_id
|
280
|
+
end
|
281
|
+
|
282
|
+
def last_id(channel)
|
283
|
+
client.max_id(channel)
|
284
|
+
end
|
285
|
+
|
286
|
+
def backlog(channel, last_id = nil)
|
287
|
+
items = client.backlog channel, last_id.to_i
|
288
|
+
|
289
|
+
items.map! do |id, data|
|
290
|
+
MessageBus::Message.new id, id, channel, data
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def global_backlog(last_id = nil)
|
295
|
+
last_id = last_id.to_i
|
296
|
+
|
297
|
+
items = client.global_backlog last_id.to_i
|
298
|
+
|
299
|
+
items.map! do |id, channel, data|
|
300
|
+
MessageBus::Message.new id, id, channel, data
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def get_message(channel, message_id)
|
305
|
+
if data = client.get_value(channel, message_id)
|
306
|
+
MessageBus::Message.new message_id, message_id, channel, data
|
307
|
+
else
|
308
|
+
nil
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def subscribe(channel, last_id = nil)
|
313
|
+
# trivial implementation for now,
|
314
|
+
# can cut down on connections if we only have one global subscriber
|
315
|
+
raise ArgumentError unless block_given?
|
316
|
+
|
317
|
+
global_subscribe(last_id) do |m|
|
318
|
+
yield m if m.channel == channel
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def process_global_backlog(highest_id)
|
323
|
+
if highest_id > client.max_id
|
324
|
+
highest_id = 0
|
325
|
+
end
|
326
|
+
|
327
|
+
global_backlog(highest_id).each do |old|
|
328
|
+
yield old
|
329
|
+
highest_id = old.global_id
|
330
|
+
end
|
331
|
+
|
332
|
+
highest_id
|
333
|
+
end
|
334
|
+
|
335
|
+
def global_unsubscribe
|
336
|
+
client.publish(postgresql_channel_name, UNSUB_MESSAGE)
|
337
|
+
@subscribed = false
|
338
|
+
end
|
339
|
+
|
340
|
+
def global_subscribe(last_id=nil, &blk)
|
341
|
+
raise ArgumentError unless block_given?
|
342
|
+
highest_id = last_id
|
343
|
+
|
344
|
+
begin
|
345
|
+
client.subscribe(postgresql_channel_name) do |on|
|
346
|
+
h = {}
|
347
|
+
|
348
|
+
on.subscribe do
|
349
|
+
if highest_id
|
350
|
+
process_global_backlog(highest_id) do |m|
|
351
|
+
h[m.global_id] = true
|
352
|
+
yield m
|
353
|
+
end
|
354
|
+
end
|
355
|
+
h = nil if h.empty?
|
356
|
+
@subscribed = true
|
357
|
+
end
|
358
|
+
|
359
|
+
on.unsubscribe do
|
360
|
+
@subscribed = false
|
361
|
+
end
|
362
|
+
|
363
|
+
on.message do |c,m|
|
364
|
+
if m == UNSUB_MESSAGE
|
365
|
+
@subscribed = false
|
366
|
+
return
|
367
|
+
end
|
368
|
+
m = MessageBus::Message.decode m
|
369
|
+
|
370
|
+
# we have 3 options
|
371
|
+
#
|
372
|
+
# 1. message came in the correct order GREAT, just deal with it
|
373
|
+
# 2. message came in the incorrect order COMPLICATED, wait a tiny bit and clear backlog
|
374
|
+
# 3. message came in the incorrect order and is lowest than current highest id, reset
|
375
|
+
|
376
|
+
if h
|
377
|
+
# If already yielded during the clear backlog when subscribing,
|
378
|
+
# don't yield a duplicate copy.
|
379
|
+
unless h.delete(m.global_id)
|
380
|
+
h = nil if h.empty?
|
381
|
+
yield m
|
382
|
+
end
|
383
|
+
else
|
384
|
+
yield m
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
rescue => error
|
389
|
+
MessageBus.logger.warn "#{error} subscribe failed, reconnecting in 1 second. Call stack\n#{error.backtrace.join("\n")}"
|
390
|
+
sleep 1
|
391
|
+
retry
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
MessageBus::BACKENDS[:postgres] = self
|
396
|
+
end
|
@@ -84,13 +84,22 @@ class MessageBus::Rack::Middleware
|
|
84
84
|
client = MessageBus::Client.new(message_bus: @bus, client_id: client_id,
|
85
85
|
user_id: user_id, site_id: site_id, group_ids: group_ids)
|
86
86
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
87
|
+
if channels = env['message_bus.channels']
|
88
|
+
if seq = env['message_bus.seq']
|
89
|
+
client.seq = seq.to_i
|
90
|
+
end
|
91
|
+
channels.each do |k, v|
|
92
92
|
client.subscribe(k, v)
|
93
93
|
end
|
94
|
+
else
|
95
|
+
request = Rack::Request.new(env)
|
96
|
+
request.POST.each do |k,v|
|
97
|
+
if k == "__seq".freeze
|
98
|
+
client.seq = v.to_i
|
99
|
+
else
|
100
|
+
client.subscribe(k, v)
|
101
|
+
end
|
102
|
+
end
|
94
103
|
end
|
95
104
|
|
96
105
|
headers = {}
|
data/lib/message_bus/version.rb
CHANGED
data/message_bus.gemspec
CHANGED
@@ -15,6 +15,8 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.name = "message_bus"
|
16
16
|
gem.require_paths = ["lib"]
|
17
17
|
gem.version = MessageBus::VERSION
|
18
|
+
gem.required_ruby_version = ">= 1.9.3"
|
18
19
|
gem.add_runtime_dependency 'rack', '>= 1.1.3'
|
19
|
-
gem.
|
20
|
+
gem.add_development_dependency 'redis'
|
21
|
+
gem.add_development_dependency 'pg'
|
20
22
|
end
|
@@ -1,10 +1,11 @@
|
|
1
|
+
require_relative '../../../spec_helper'
|
1
2
|
asset_directory = File.expand_path('../../../../../assets', __FILE__)
|
2
3
|
asset_file_paths = Dir.glob(File.join(asset_directory, 'message-bus.js'))
|
3
4
|
asset_file_names = asset_file_paths.map{|e| File.basename(e) }
|
4
5
|
|
5
6
|
describe asset_file_names do
|
6
7
|
it 'should contain .js files' do
|
7
|
-
|
8
|
+
asset_file_names.must_include('message-bus.js')
|
8
9
|
end
|
9
10
|
end
|
10
11
|
|
@@ -12,8 +13,7 @@ asset_file_paths.each do | path |
|
|
12
13
|
describe "Asset file #{File.basename(path).inspect}" do
|
13
14
|
it 'should be encodable as UTF8' do
|
14
15
|
binary_data = File.open(path, 'rb'){|f| f.read }
|
15
|
-
|
16
|
-
expect(encode_block).not_to raise_error
|
16
|
+
binary_data.encode(Encoding::UTF_8)
|
17
17
|
end
|
18
18
|
end
|
19
|
-
end
|
19
|
+
end
|