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.
@@ -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 (you need to include it in your Gemfile
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
- fanout_options: {},
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
- queue_options: {},
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
- Thread.current[CHANNEL_KEY] ||= @bunny.create_channel(
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 = channel.fanout(event.type.to_s, fanout_options)
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 = channel.fanout(type.to_s, fanout_options)
127
- queue = channel.queue((processor || "main").to_s, queue_options)
128
- queue.bind(fanout)
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
@@ -104,7 +104,7 @@ module Startback
104
104
  op_class: op.class.name.to_s,
105
105
  value: value,
106
106
  }
107
- JSON.fast_generate(key)
107
+ JSON.generate(key)
108
108
  end
109
109
 
110
110
  def defaults
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
- MAJOR = 1
4
- MINOR = 2
5
- TINY = 4
3
+ MAJOR = 2
4
+ MINOR = 1
5
+ TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -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
- (development? ? @cache_headers[:development] : @cache_headers[:production]).merge(hs)
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
- "Cache-Control" => DEVELOPMENT_CACHE_CONTROL
77
+ "cache-control" => DEVELOPMENT_CACHE_CONTROL
72
78
  },
73
79
  production: {
74
- "Cache-Control" => PRODUCTION_CACHE_CONTROL
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 : {"Cache-Control" => 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['Content-Length'] = '0'
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].dup
80
+ headers = Rack::Headers[@options[:headers]]
75
81
  if bounce = do_bounce(origin)
76
82
  headers['Access-Control-Allow-Origin'] = bounce
77
83
  else
@@ -32,7 +32,7 @@ module Startback
32
32
 
33
33
  def call(env)
34
34
  if debug_msg = check!(env)
35
- [ 200, { "Content-Type" => "text/plain" }, Array(debug_msg) ]
35
+ [ 200, { "content-type" => "text/plain" }, Array(debug_msg) ]
36
36
  else
37
37
  [ 204, {}, [] ]
38
38
  end
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