startback 1.2.4 → 2.1.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +954 -0
- data/README.md +45 -2
- data/UPGRADING.md +460 -0
- data/lib/startback/caching/entity_cache.rb +2 -2
- data/lib/startback/event/bus/bunny/async.rb +131 -9
- data/lib/startback/security/rate_limiter.rb +1 -1
- data/lib/startback/version.rb +3 -3
- data/lib/startback/web/auto_caching.rb +10 -4
- data/lib/startback/web/cors_headers.rb +8 -2
- data/lib/startback/web/health_check.rb +1 -1
- data/spec/spec_helper.rb +7 -0
- data/spec/support/bunny_broker.rb +145 -0
- data/spec/unit/event/bus/bunny/test_async.rb +296 -0
- data/spec/unit/web/test_auto_caching.rb +19 -0
- data/spec/unit/web/test_cors_headers.rb +21 -0
- metadata +142 -58
|
@@ -5,14 +5,29 @@ module Startback
|
|
|
5
5
|
module Bunny
|
|
6
6
|
#
|
|
7
7
|
# Asynchronous implementation of the bus abstraction, on top of RabbitMQ
|
|
8
|
-
# and using the 'bunny' gem
|
|
9
|
-
# yourself: it is NOT a startback official dependency).
|
|
8
|
+
# and using the 'bunny' gem.
|
|
10
9
|
#
|
|
11
10
|
# This bus implementation emits events by dumping them to RabbitMQ using
|
|
12
11
|
# the event type as exchange name. Listeners may use the `processor`
|
|
13
12
|
# parameter to specify the queue name ; otherwise a default "main" queue
|
|
14
13
|
# is used.
|
|
15
14
|
#
|
|
15
|
+
# WARNING: unlike Bus::Memory::Async, which hands listeners an Event
|
|
16
|
+
# instance, this bus hands them the **raw JSON String** read off the
|
|
17
|
+
# queue. A listener moved from the memory bus to this one therefore
|
|
18
|
+
# receives something else, silently. Parse it yourself, e.g. with
|
|
19
|
+
# `Startback::Event.json(body, context)`.
|
|
20
|
+
#
|
|
21
|
+
# The exchange and queue are declared **durable** by default. RabbitMQ
|
|
22
|
+
# refuses transient non-exclusive queues from 4.3 on, so the previous
|
|
23
|
+
# defaults stop working there entirely.
|
|
24
|
+
#
|
|
25
|
+
# An exchange or queue that already exists with other properties -- one
|
|
26
|
+
# an older Startback declared transient -- is **adopted as it stands**
|
|
27
|
+
# rather than redeclared, so upgrading needs no broker-side migration.
|
|
28
|
+
# A warning is logged, and the durable declaration takes effect on its
|
|
29
|
+
# own at the next broker restart, transient objects not surviving one.
|
|
30
|
+
#
|
|
16
31
|
# Examples:
|
|
17
32
|
#
|
|
18
33
|
# # Connects to RabbitMQ using all default options
|
|
@@ -46,10 +61,21 @@ module Startback
|
|
|
46
61
|
connection_options: nil,
|
|
47
62
|
|
|
48
63
|
# (optional) The options to use for the emitter/listener fanout
|
|
49
|
-
|
|
64
|
+
#
|
|
65
|
+
# Durable by default, so that the exchange and the bindings
|
|
66
|
+
# pointing at it survive a broker restart. A durable queue bound
|
|
67
|
+
# to a transient exchange would come back alone, with nothing
|
|
68
|
+
# routing to it.
|
|
69
|
+
fanout_options: { durable: true },
|
|
50
70
|
|
|
51
71
|
# (optional) The options to use for the listener queue
|
|
52
|
-
|
|
72
|
+
#
|
|
73
|
+
# Durable by default, because RabbitMQ 4 refuses transient
|
|
74
|
+
# non-exclusive queues: declaring one fails the channel, and
|
|
75
|
+
# `listen` never receives anything. Durability is also what a
|
|
76
|
+
# named processor queue wants -- events waiting in it outlive a
|
|
77
|
+
# broker restart rather than being dropped on the floor.
|
|
78
|
+
queue_options: { durable: true },
|
|
53
79
|
|
|
54
80
|
# (optional) Default event factory to use, if any
|
|
55
81
|
event_factory: nil,
|
|
@@ -74,12 +100,17 @@ module Startback
|
|
|
74
100
|
def initialize(options = {})
|
|
75
101
|
options = { url: options } if options.is_a?(String)
|
|
76
102
|
@options = DEFAULT_OPTIONS.merge(options)
|
|
103
|
+
@topology = {}
|
|
104
|
+
@topology_lock = Mutex.new
|
|
77
105
|
connect if @options[:autoconnect]
|
|
78
106
|
end
|
|
79
107
|
attr_reader :options
|
|
80
108
|
|
|
81
109
|
def connect
|
|
82
110
|
disconnect
|
|
111
|
+
# What the broker holds is only known for a given connection: a
|
|
112
|
+
# restart in between wipes every transient exchange and queue.
|
|
113
|
+
@topology_lock.synchronize { @topology = {} }
|
|
83
114
|
conn = options[:connection_options] || options[:url]
|
|
84
115
|
try_max_times(10) do
|
|
85
116
|
@bunny = ::Bunny.new(conn)
|
|
@@ -106,7 +137,16 @@ module Startback
|
|
|
106
137
|
raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
|
|
107
138
|
end
|
|
108
139
|
|
|
109
|
-
|
|
140
|
+
# A channel-level error closes the channel, and bunny does not
|
|
141
|
+
# reopen it. Since this one is cached per thread, a single such
|
|
142
|
+
# error used to leave the thread with a dead channel forever:
|
|
143
|
+
# every later emit failed with "cannot use a closed channel",
|
|
144
|
+
# whatever the event type, and `stop_errors` hid it. Dropping a
|
|
145
|
+
# closed channel here is what makes the bus recover on its own.
|
|
146
|
+
current = Thread.current[CHANNEL_KEY]
|
|
147
|
+
current = nil unless current.nil? || current.open?
|
|
148
|
+
|
|
149
|
+
Thread.current[CHANNEL_KEY] = current || @bunny.create_channel(
|
|
110
150
|
nil,
|
|
111
151
|
consumer_pool_size, # consumer_pool_size
|
|
112
152
|
abort_on_exception? # consumer_pool_abort_on_exception
|
|
@@ -115,7 +155,7 @@ module Startback
|
|
|
115
155
|
|
|
116
156
|
def emit(event)
|
|
117
157
|
stop_errors(self, "emit", event.context) do
|
|
118
|
-
fanout =
|
|
158
|
+
fanout = declare(:fanout, event.type.to_s, fanout_options)
|
|
119
159
|
fanout.publish(event.to_json)
|
|
120
160
|
end
|
|
121
161
|
end
|
|
@@ -123,9 +163,11 @@ module Startback
|
|
|
123
163
|
def listen(type, processor = nil, listener = nil, &bl)
|
|
124
164
|
raise ArgumentError, "A listener must be provided" unless listener || bl
|
|
125
165
|
|
|
126
|
-
fanout =
|
|
127
|
-
queue =
|
|
128
|
-
queue
|
|
166
|
+
fanout = declare(:fanout, type.to_s, fanout_options)
|
|
167
|
+
queue = declare(:queue, (processor || "main").to_s, queue_options)
|
|
168
|
+
# Bound by name on purpose: declaring the queue may have renewed
|
|
169
|
+
# the channel, in which case `fanout` belongs to a closed one.
|
|
170
|
+
queue.bind(fanout.name)
|
|
129
171
|
queue.subscribe do |delivery_info, properties, body|
|
|
130
172
|
stop_errors(self, "listen") do
|
|
131
173
|
(listener || bl).call(body)
|
|
@@ -135,6 +177,86 @@ module Startback
|
|
|
135
177
|
|
|
136
178
|
protected
|
|
137
179
|
|
|
180
|
+
# Declares an exchange (`kind` = :fanout) or a queue (:queue) with
|
|
181
|
+
# `options`, adopting one that already exists with other properties
|
|
182
|
+
# instead of failing on it.
|
|
183
|
+
#
|
|
184
|
+
# Startback used to declare both transient. AMQP refuses to
|
|
185
|
+
# redeclare an object with different properties, so an application
|
|
186
|
+
# upgrading to the durable defaults would get the broker closing its
|
|
187
|
+
# channel with PRECONDITION_FAILED -- and, `emit` being wrapped in
|
|
188
|
+
# `stop_errors`, would silently stop emitting rather than crash.
|
|
189
|
+
#
|
|
190
|
+
# Requiring a coordinated broker restart to avoid that is a poor
|
|
191
|
+
# deal for something the upgrade gains nothing from, so adopt what
|
|
192
|
+
# is there: `passive: true` matches an existing object whatever its
|
|
193
|
+
# properties. Transient objects disappear at the next broker
|
|
194
|
+
# restart anyway, and the durable declaration then wins on its own,
|
|
195
|
+
# with nobody having had to do anything.
|
|
196
|
+
def declare(kind, name, options)
|
|
197
|
+
channel.public_send(kind, name, topology_options(kind, name, options))
|
|
198
|
+
rescue ::Bunny::PreconditionFailed, ::Bunny::NotFound
|
|
199
|
+
# The topology moved under us -- a broker restart took a transient
|
|
200
|
+
# object away, or another application redeclared it. Forget what
|
|
201
|
+
# was known of it and look again.
|
|
202
|
+
forget_topology(kind, name)
|
|
203
|
+
renew_channel!
|
|
204
|
+
channel.public_send(kind, name, topology_options(kind, name, options))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The options that actually work for `name`: the requested ones,
|
|
208
|
+
# unless an incompatible object is already there, in which case
|
|
209
|
+
# `passive: true`, which matches whatever its properties are.
|
|
210
|
+
#
|
|
211
|
+
# Memoized, because `emit` declares the exchange on every call and
|
|
212
|
+
# the answer only changes across connections.
|
|
213
|
+
def topology_options(kind, name, requested)
|
|
214
|
+
key = [kind, name]
|
|
215
|
+
@topology_lock.synchronize do
|
|
216
|
+
return @topology[key] if @topology.key?(key)
|
|
217
|
+
end
|
|
218
|
+
probed = probe_topology(kind, name, requested)
|
|
219
|
+
@topology_lock.synchronize { @topology[key] = probed }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def forget_topology(kind, name)
|
|
223
|
+
@topology_lock.synchronize { @topology.delete([kind, name]) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Tries the requested options on a **scratch channel**, never on the
|
|
227
|
+
# one the bus works with. A rejected declaration is a channel-level
|
|
228
|
+
# error, and the broker closes the channel it happened on: probing
|
|
229
|
+
# on the shared channel would take down every consumer registered
|
|
230
|
+
# there, so emitting would silently unsubscribe the listeners.
|
|
231
|
+
def probe_topology(kind, name, requested)
|
|
232
|
+
scratch = @bunny.create_channel
|
|
233
|
+
begin
|
|
234
|
+
scratch.public_send(kind, name, requested)
|
|
235
|
+
requested
|
|
236
|
+
rescue ::Bunny::PreconditionFailed
|
|
237
|
+
log(:warn, {
|
|
238
|
+
op: "#{self.class.name}#declare",
|
|
239
|
+
op_data: {
|
|
240
|
+
kind: kind,
|
|
241
|
+
name: name,
|
|
242
|
+
msg: "Adopting an existing #{kind} whose properties differ " \
|
|
243
|
+
"from the requested ones. It will be declared as " \
|
|
244
|
+
"requested after the next broker restart."
|
|
245
|
+
}
|
|
246
|
+
}, self.options[:context])
|
|
247
|
+
{ passive: true }
|
|
248
|
+
ensure
|
|
249
|
+
scratch.close if scratch.open?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Forgets the current channel, which a channel-level error left
|
|
254
|
+
# closed, so that `channel` opens a fresh one.
|
|
255
|
+
def renew_channel!
|
|
256
|
+
Thread.current[CHANNEL_KEY] = nil
|
|
257
|
+
channel
|
|
258
|
+
end
|
|
259
|
+
|
|
138
260
|
def consumer_pool_size
|
|
139
261
|
options[:consumer_pool_size]
|
|
140
262
|
end
|
data/lib/startback/version.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
|
|
1
3
|
module Startback
|
|
2
4
|
module Web
|
|
3
5
|
#
|
|
@@ -53,8 +55,12 @@ module Startback
|
|
|
53
55
|
|
|
54
56
|
protected
|
|
55
57
|
|
|
58
|
+
# Rack::Headers is used so that the defaults set here are actually
|
|
59
|
+
# overriden by the downstream application, whatever the case it uses
|
|
60
|
+
# for its own header names.
|
|
56
61
|
def patch_response_headers(hs)
|
|
57
|
-
|
|
62
|
+
defaults = development? ? @cache_headers[:development] : @cache_headers[:production]
|
|
63
|
+
Rack::Headers[defaults].merge(hs)
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
def development?
|
|
@@ -68,16 +74,16 @@ module Startback
|
|
|
68
74
|
def default_headers
|
|
69
75
|
{
|
|
70
76
|
development: {
|
|
71
|
-
"
|
|
77
|
+
"cache-control" => DEVELOPMENT_CACHE_CONTROL
|
|
72
78
|
},
|
|
73
79
|
production: {
|
|
74
|
-
"
|
|
80
|
+
"cache-control" => PRODUCTION_CACHE_CONTROL
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
def normalize_headers(h)
|
|
80
|
-
Hash[h.map{|k,v| [k, v.is_a?(Hash) ? v : {"
|
|
86
|
+
Hash[h.map{|k,v| [k, v.is_a?(Hash) ? v : {"cache-control" => v} ] }]
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
end # class AutoCaching
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
|
|
1
3
|
module Startback
|
|
2
4
|
module Web
|
|
3
5
|
#
|
|
@@ -62,7 +64,8 @@ module Startback
|
|
|
62
64
|
headers = cors_headers(origin).merge(headers)
|
|
63
65
|
end
|
|
64
66
|
if env['REQUEST_METHOD'] == 'OPTIONS'
|
|
65
|
-
headers
|
|
67
|
+
headers = Rack::Headers[headers]
|
|
68
|
+
headers['content-length'] = '0'
|
|
66
69
|
status, headers, body = [204, headers, []]
|
|
67
70
|
end
|
|
68
71
|
[status, headers, body]
|
|
@@ -70,8 +73,11 @@ module Startback
|
|
|
70
73
|
|
|
71
74
|
private
|
|
72
75
|
|
|
76
|
+
# Rack::Headers is used so that the CORS headers set here are actually
|
|
77
|
+
# overriden by the downstream application, whatever the case it uses
|
|
78
|
+
# for its own header names.
|
|
73
79
|
def cors_headers(origin)
|
|
74
|
-
headers = @options[:headers]
|
|
80
|
+
headers = Rack::Headers[@options[:headers]]
|
|
75
81
|
if bounce = do_bounce(origin)
|
|
76
82
|
headers['Access-Control-Allow-Origin'] = bounce
|
|
77
83
|
else
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
# Sinatra 4 restricts the Host header to localhost-like values in the
|
|
2
|
+
# `development` environment, which is the one used when RACK_ENV is unset.
|
|
3
|
+
# Rack::Test issues requests against `example.org`, hence the need to be
|
|
4
|
+
# explicit about running in test mode here.
|
|
5
|
+
ENV["RACK_ENV"] ||= "test"
|
|
6
|
+
|
|
1
7
|
require 'startback'
|
|
2
8
|
require 'startback/caching'
|
|
3
9
|
require 'startback/event'
|
|
@@ -6,6 +12,7 @@ require 'startback/audit'
|
|
|
6
12
|
require 'startback/security'
|
|
7
13
|
require 'rack/test'
|
|
8
14
|
require 'ostruct'
|
|
15
|
+
require 'support/bunny_broker'
|
|
9
16
|
|
|
10
17
|
module SpecHelpers
|
|
11
18
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Support for the specs that need a real RabbitMQ broker, i.e. those covering
|
|
5
|
+
# Startback::Event::Bus::Bunny::Async. There is no way to cover that bus
|
|
6
|
+
# meaningfully without one: mocking bunny would only assert that Startback
|
|
7
|
+
# calls the methods Startback calls.
|
|
8
|
+
#
|
|
9
|
+
# The broker is located through STARTBACK_BUS_BUNNY_ASYNC_URL, which is also
|
|
10
|
+
# the variable the bus itself reads, so pointing the suite at a broker and
|
|
11
|
+
# pointing an application at one are the same gesture.
|
|
12
|
+
#
|
|
13
|
+
# STARTBACK_BUS_BUNNY_ASYNC_URL=amqp://guest:guest@localhost:5672
|
|
14
|
+
#
|
|
15
|
+
# `make rabbitmq.up` starts one locally. When no broker answers, those specs
|
|
16
|
+
# are skipped, so that a contributor without docker still gets a green suite.
|
|
17
|
+
#
|
|
18
|
+
# That skip is a trap on CI, where a broken service container would quietly
|
|
19
|
+
# take the coverage away instead of failing. STARTBACK_SPEC_REQUIRE_BUNNY=1
|
|
20
|
+
# turns "no broker" into a hard error, and CI sets it.
|
|
21
|
+
#
|
|
22
|
+
module BunnyBroker
|
|
23
|
+
extend self
|
|
24
|
+
|
|
25
|
+
DEFAULT_URL = "amqp://guest:guest@localhost:5672"
|
|
26
|
+
|
|
27
|
+
def url
|
|
28
|
+
ENV['STARTBACK_BUS_BUNNY_ASYNC_URL'] || DEFAULT_URL
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def required?
|
|
32
|
+
!ENV['STARTBACK_SPEC_REQUIRE_BUNNY'].to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Whether a broker answers, memoized: the specs ask once per example and
|
|
36
|
+
# connecting is not free. `false` is a legitimate memoized answer, hence
|
|
37
|
+
# `defined?` rather than `||=`.
|
|
38
|
+
def available?
|
|
39
|
+
return @available if defined?(@available)
|
|
40
|
+
|
|
41
|
+
@available = begin
|
|
42
|
+
require 'bunny'
|
|
43
|
+
conn = ::Bunny.new(url, log_level: :fatal, network_recovery_interval: 0,
|
|
44
|
+
connection_timeout: 2, continuation_timeout: 4000)
|
|
45
|
+
conn.start
|
|
46
|
+
conn.close
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError, LoadError => ex
|
|
49
|
+
@unavailable_reason = "#{ex.class}: #{ex.message}"
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unavailable_reason
|
|
55
|
+
@unavailable_reason
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Called from a `before` hook, with the example context as argument --
|
|
59
|
+
# `skip` is a method of the example group instance, not of the Example
|
|
60
|
+
# object, whose own `skip` is a metadata reader taking no argument.
|
|
61
|
+
def skip_unless_available!(context)
|
|
62
|
+
return if available?
|
|
63
|
+
|
|
64
|
+
msg = "No RabbitMQ broker at #{url} (#{unavailable_reason})"
|
|
65
|
+
raise "#{msg}. STARTBACK_SPEC_REQUIRE_BUNNY is set, so this is an error." if required?
|
|
66
|
+
|
|
67
|
+
context.skip("#{msg}. Start one with `make rabbitmq.up`.")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Exchanges and queues outlive an example now that both are durable, and
|
|
71
|
+
# redeclaring one with different options raises PreconditionFailed. Each
|
|
72
|
+
# example therefore works on names nobody else uses.
|
|
73
|
+
def unique(prefix)
|
|
74
|
+
"#{prefix}-#{SecureRandom.hex(6)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Removes the topology an example created. Durable means the broker would
|
|
78
|
+
# otherwise keep it forever.
|
|
79
|
+
def delete_topology(exchanges: [], queues: [])
|
|
80
|
+
conn = ::Bunny.new(url, log_level: :fatal)
|
|
81
|
+
conn.start
|
|
82
|
+
ch = conn.create_channel
|
|
83
|
+
queues.each { |q| ch.queue_delete(q) rescue nil }
|
|
84
|
+
exchanges.each { |x| ch.exchange_delete(x) rescue nil }
|
|
85
|
+
conn.close
|
|
86
|
+
rescue StandardError
|
|
87
|
+
# Best effort: a cleanup failure must not turn a passing example red.
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether the broker still lets a *transient non-exclusive queue* be
|
|
91
|
+
# declared. RabbitMQ denies that from 4.3 on, which is the very reason the
|
|
92
|
+
# durable defaults exist -- but it also means the queue half of the
|
|
93
|
+
# adoption path cannot be set up on such a broker. Exchanges are not
|
|
94
|
+
# restricted, so that half is always exercised.
|
|
95
|
+
def transient_queues_permitted?
|
|
96
|
+
return @transient_queues if defined?(@transient_queues)
|
|
97
|
+
|
|
98
|
+
@transient_queues = begin
|
|
99
|
+
conn = ::Bunny.new(url, log_level: :fatal, continuation_timeout: 4000)
|
|
100
|
+
conn.start
|
|
101
|
+
name = unique("probe-transient")
|
|
102
|
+
begin
|
|
103
|
+
ch = conn.create_channel
|
|
104
|
+
ch.queue(name, {})
|
|
105
|
+
ch.queue_delete(name)
|
|
106
|
+
true
|
|
107
|
+
rescue StandardError
|
|
108
|
+
false
|
|
109
|
+
ensure
|
|
110
|
+
conn.close rescue nil
|
|
111
|
+
end
|
|
112
|
+
rescue StandardError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same trap as skip_unless_available!, one level down: on the CI job whose
|
|
118
|
+
# whole point is to run the adoption examples, a broker image bumped past
|
|
119
|
+
# 4.2 would make them skip and take the coverage away silently.
|
|
120
|
+
def skip_unless_transient_queues!(context)
|
|
121
|
+
return if transient_queues_permitted?
|
|
122
|
+
|
|
123
|
+
msg = "Broker at #{url} denies transient non-exclusive queues " \
|
|
124
|
+
"(RabbitMQ >= 4.3), so the legacy topology cannot be created here"
|
|
125
|
+
if !ENV['STARTBACK_SPEC_REQUIRE_TRANSIENT_QUEUES'].to_s.strip.empty?
|
|
126
|
+
raise "#{msg}. STARTBACK_SPEC_REQUIRE_TRANSIENT_QUEUES is set, so this " \
|
|
127
|
+
"is an error: this job exists to run these examples."
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context.skip("#{msg}. The bus-legacy CI job covers it against RabbitMQ 4.1.")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Blocks until `bl` returns something truthy, or the timeout expires.
|
|
134
|
+
# Returns the value, or nil. Polling rather than sleeping a fixed delay
|
|
135
|
+
# keeps the suite fast when the broker is responsive, which it usually is.
|
|
136
|
+
def wait_for(timeout = 10, &bl)
|
|
137
|
+
deadline = Time.now + timeout
|
|
138
|
+
while Time.now < deadline
|
|
139
|
+
value = bl.call
|
|
140
|
+
return value if value
|
|
141
|
+
sleep 0.05
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|