startback 2.0.0 → 2.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74d6c4214ad33ba472543754def2abcda5d8b4bb00b632ff7521c7938c1bb9e5
4
- data.tar.gz: f5a3e38d97b24cfc6081e4f2dbc6939bbc3ee98f216522b85d88a82e3214ebc5
3
+ metadata.gz: 618541ed038c6a619973934e3430a23eebd0ac9606f91d2535fbd7354e2835b1
4
+ data.tar.gz: 0161c46f4272637e7ae3862eca10d5098c2f109cc28ef9e019826ca2d627aa79
5
5
  SHA512:
6
- metadata.gz: 3c817bcfe4072d5a5b7a529e58d9cefde85afb6306fd4c173e5b478a2b5fb56096a30ca5ae1ac18fcb56dfd6ed7102eb2a55fe818da4aefa5739800078b24b3c
7
- data.tar.gz: b48bb2f32f7fb30be9359fe6758042574099a043abf4e9a82f2ec235b5e3e25b9241b2964effb8ab95fdb47943fede69a255aa0d5fc45bf324aa858497c28085
6
+ metadata.gz: a12d45c8d149de30c27658638572bc32d9d5ba6d8fb0fd92a5a94a14681be318ce79473463476e5cd67c750639affbdb5d25aafc20ae4b3accd6a649a2762d20
7
+ data.tar.gz: 8b3e16023edb27a7969984576b512974ad1a68277e9d08e94b8399b1c9c475a1063f0e5afd4b49141577a58ab00853ac53193bfa3d018875b87e3dec1a21d839
data/CHANGELOG.md CHANGED
@@ -1,3 +1,178 @@
1
+ ## 2.1.1 - 2026-10-23
2
+
3
+ The Bunny event bus keeps consuming across a broker restart. **Nothing to do
4
+ to get the fix**; `alive?` is there for whoever wants a liveness probe.
5
+
6
+ ### Listeners survive a broker restart
7
+
8
+ 2.1.0 adopts a pre-existing exchange or queue passively when its properties
9
+ differ from the requested ones -- what an older Startback left behind, a
10
+ transient topology. Bunny does not record passive declarations for topology
11
+ recovery. After a broker restart the transient objects are gone, Bunny's
12
+ replay of the queue binding fails with `NOT_FOUND`, the broker closes the
13
+ channel the consumer lived on, and the application keeps running without
14
+ consuming anything, with nothing louder than an error line in Bunny's log.
15
+ It took a manual restart to get it back.
16
+
17
+ Once Bunny reports the connection recovered, the bus now forgets what it
18
+ knew of the topology and subscribes again every listener whose consumer did
19
+ not come back, declaring the exchange and queue as requested -- durable,
20
+ since the transient ones are gone. Listeners whose consumer survived are left
21
+ alone. An application's own `recovery_completed` connection option is still
22
+ called, first.
23
+
24
+ ### `Bus::Bunny::Async#alive?`
25
+
26
+ True when the bus is connected and every listener still has a consumer on an
27
+ open channel. A recovery that fails to subscribe a listener again leaves it
28
+ dead and logs the error; Bunny's own recovery may also never complete at all.
29
+ `alive?` reports both, which makes it the right thing for a liveness probe to
30
+ check:
31
+
32
+ ```ruby
33
+ map '/health/bus' do
34
+ run ->(_env) { bus.async.alive? ? [204, {}, []] : [503, {}, ["Bus is not consuming"]] }
35
+ end
36
+ ```
37
+
38
+ ## 2.1.0 - 2026-09-30
39
+
40
+ Ruby 4.0 support, docker images for several ruby versions, and the Bunny
41
+ event bus finally under test -- which is how two silent bugs and a RabbitMQ
42
+ 4.3 incompatibility came to light.
43
+
44
+ **Almost nothing here asks anything of you.** Startback's API is unchanged
45
+ and the bus upgrade needs no broker-side migration. The one thing that does
46
+ move: the un-suffixed docker tags go from Ruby 3.3 to Ruby 3.4, a minor
47
+ bump. The details are below because the *reasons* matter, not because there
48
+ is much to do.
49
+
50
+ ### Ruby 4.0 is supported
51
+
52
+ The test grid now runs **Ruby 3.2, 3.3, 3.4 and 4.0** -- the whole range
53
+ `required_ruby_version` allows, so the floor is a tested claim rather than an
54
+ assumed one. Startback needed no source change for 4.0: the gem,
55
+ `startback-jobs`, `startback-web` and the example application's webspicy
56
+ specs are green on all four, with no deprecation warning from Startback's own
57
+ code.
58
+
59
+ `required_ruby_version` stays `>= 3.2`.
60
+
61
+ ### Docker images are released for Ruby 3.4 and 4.0
62
+
63
+ Images are built for each ruby version of the release matrix. The tags that
64
+ name no ruby version follow `DEFAULT_MRI_VERSION`, which is **Ruby 3.4**:
65
+ 2.0.0 published them from Ruby 3.3, so `enspirit/startback:api` and `:web`
66
+ move by one minor version here, and stay put from now on.
67
+
68
+ | Tag | Ruby |
69
+ |---|---|
70
+ | `enspirit/startback:api`, `:api-2.1.0`, `:api-2.1` | 3.4 (was 3.3 in 2.0.0) |
71
+ | `enspirit/startback:api-ruby3.4`, `:api-2.1.0-ruby3.4`, `:api-2.1-ruby3.4` | 3.4 |
72
+ | `enspirit/startback:api-ruby4.0`, `:api-2.1.0-ruby4.0`, `:api-2.1-ruby4.0` | 4.0 |
73
+
74
+ Same for the `web` target. **No Ruby 3.3 image is published any more**: an
75
+ application pinned to `:api-ruby3.3` stays at 2.0.0 and should move to
76
+ `:api-ruby3.4`. Only `DEFAULT_MRI_VERSION` in the Makefile publishes the
77
+ un-suffixed tags, so adding a ruby version to the matrix can
78
+ never change what `:api` means depending on which release job finished last.
79
+ `make images.all` walks the whole matrix the way the release workflow does.
80
+
81
+ The `web` image moves from **nodejs 20 to nodejs 22**, node 20 having reached
82
+ end of life in April 2026.
83
+
84
+ ### The Bunny event bus is now covered by the test suite
85
+
86
+ It was the one part of Startback with no automated coverage, on the grounds
87
+ that the grid had no broker. It now has one, and 19 specs that talk to it for
88
+ real -- mocking bunny would only assert that Startback calls the methods
89
+ Startback calls. They cover connecting, autoconnect, the emit/listen round
90
+ trip, fanout across processor queues, type isolation, adoption of a
91
+ pre-existing topology, and the asynchronous contract that emit errors must
92
+ not reach the emitter.
93
+
94
+ Without a broker those specs skip, so `make tests` stays green for a
95
+ contributor without docker; `make rabbitmq.up` starts one through the new
96
+ `docker-compose.yml`. CI sets `STARTBACK_SPEC_REQUIRE_BUNNY=1`, which turns
97
+ "no broker" into a failure, because otherwise a broken service container
98
+ would take the coverage away without anything turning red.
99
+
100
+ ### The bus stops working on RabbitMQ 4.3, and now does not
101
+
102
+ `queue_options` defaulted to `{}`, which declares a *transient non-exclusive*
103
+ queue. RabbitMQ deprecated that, and flips it from `permitted_by_default` to
104
+ `denied_by_default` in 4.3:
105
+
106
+ | RabbitMQ | transient non-exclusive queues | Startback <= 2.0 bus |
107
+ |---|---|---|
108
+ | 4.0, 4.1, 4.2 | permitted | works |
109
+ | **4.3+** | **denied** | **`listen` receives nothing, then `Timeout::Error`** |
110
+
111
+ The defaults are now durable for both the exchange and the queue, which is
112
+ also what a named processor queue wants: events waiting in it outlive a
113
+ broker restart instead of being dropped, and a durable queue bound to a
114
+ transient exchange would come back after a restart with nothing routing to
115
+ it.
116
+
117
+ ```ruby
118
+ fanout_options: { durable: true },
119
+ queue_options: { durable: true },
120
+ ```
121
+
122
+ **No migration is required, deliberately.** AMQP refuses to redeclare an
123
+ object with different properties, so on a broker up since an older Startback
124
+ declared its topology the durable declaration is rejected with
125
+ `PRECONDITION_FAILED`. Rather than make that your problem, the bus *adopts*
126
+ whatever is already there (`passive: true` matches an existing object
127
+ whatever its properties) and logs a warning. Transient objects do not survive
128
+ a broker restart, so the durable declaration takes over by itself at the next
129
+ one, with nobody having done anything.
130
+
131
+ Left alone that would have been a nasty upgrade: `emit` runs inside
132
+ `stop_errors`, so the rejection was swallowed and the application went on
133
+ returning 200s while silently dropping every event.
134
+
135
+ Applications already passing their own `queue_options`/`fanout_options` are
136
+ unaffected: explicit options still win.
137
+
138
+ ### Two silent bunny bugs fixed
139
+
140
+ Both predate this release and neither announced itself:
141
+
142
+ * **A dead channel was cached forever.** A channel-level error closes the
143
+ channel, and the bus kept one per thread without checking it was still
144
+ open. One such error therefore broke the bus for that thread permanently --
145
+ every later `emit` failing with `cannot use a closed channel`, for *any*
146
+ event type, swallowed by `stop_errors`. The channel is now renewed when
147
+ found closed.
148
+
149
+ * **Declaring could unsubscribe your listeners.** A rejected declaration
150
+ closes the channel it happened on, which was the shared one carrying the
151
+ consumers. Topology is now probed on a scratch channel, memoized per
152
+ connection so `emit` does not pay for it on every call.
153
+
154
+ ### Known wart, documented rather than fixed
155
+
156
+ A Bunny listener receives the raw JSON `String` off the queue, where a
157
+ `Bus::Memory::Async` listener receives a `Startback::Event`. Listeners are
158
+ not portable between the two busses. The bus even carries a `factor_event`
159
+ method for this, defined and never called. A spec pins the current
160
+ behaviour; changing it would break every existing listener.
161
+
162
+ ### Other changes
163
+
164
+ * CI housekeeping: `actions/checkout` v2/v3 -> v4, `actions/setup-node` v3
165
+ -> v4 on nodejs 22 (14 was long end of life, and it is the javascript
166
+ runtime `startback-web`'s sprockets specs need), `docker/login-action` v1
167
+ -> v3. The image release workflow reads the version to tag from
168
+ `github.ref_name` rather than `git describe --contains`, which silently
169
+ yielded nothing on a shallow checkout and downgraded a release to
170
+ unversioned tags.
171
+
172
+ * The 2.0.0 notes said `benchmark`, `json`, `logger` and `ostruct` stop being
173
+ default gems "in Ruby 3.5". That release became 4.0, and `json` is still a
174
+ default gem there. Corrected in place.
175
+
1
176
  ## 2.0.0 - 2026-09-29
2
177
 
3
178
  Major dependency upgrade. Sinatra 4 (hence Rack 3) is now required, and the
@@ -130,7 +305,7 @@ however resolve to the newest of each, and each has its own breaking changes:
130
305
 
131
306
  * `benchmark`, `json`, `logger` and `ostruct` are now explicit runtime
132
307
  dependencies. They are required by `lib/startback.rb` and stop being default
133
- gems in Ruby 3.5.
308
+ gems in Ruby 4.0 -- `json` excepted, which is still one there.
134
309
 
135
310
  * The example application and both contrib gems moved to webspicy 1.0, whose
136
311
  own ranges are what makes finitio 1.0, http 6 and rack-robustness 2.0
@@ -144,6 +319,7 @@ however resolve to the newest of each, and each has its own breaking changes:
144
319
  RabbitMQ — so bunny 3 is upgraded but unverified by the suite. Likewise,
145
320
  `http`, `jwt`, `puma`, `nokogiri`, `tzinfo`, `i18n` and `mustache` are never
146
321
  loaded by Startback, so the suite says nothing about their new majors.
322
+ *(The bus is covered as of 2.1.0.)*
147
323
 
148
324
  * Applications testing with webspicy must move to its 1.x line. Every 0.27.x
149
325
  release requires `finitio < 0.13`, `http < 6.0` and `rack-robustness < 2.0`,
data/README.md CHANGED
@@ -22,3 +22,45 @@ This gem uses semantic versioning. The public API is defined as follows:
22
22
  main `CMD`.
23
23
 
24
24
  Upgrading across a major version? See [UPGRADING.md](UPGRADING.md).
25
+
26
+ ## Supported rubies
27
+
28
+ CI runs the suite on **Ruby 3.2, 3.3, 3.4 and 4.0** -- the whole range
29
+ `required_ruby_version` allows. Docker images are released for 3.4 and 4.0.
30
+
31
+ ## Running the tests
32
+
33
+ make tests
34
+
35
+ The `Startback::Event::Bus::Bunny::Async` specs need a real RabbitMQ broker --
36
+ mocking bunny would only assert that Startback calls the methods Startback
37
+ calls. Start one and point the suite at it:
38
+
39
+ make rabbitmq.up
40
+ export STARTBACK_BUS_BUNNY_ASYNC_URL=amqp://guest:guest@localhost:5672
41
+ make tests
42
+ make rabbitmq.down
43
+
44
+ Without a broker those specs **skip**, and the suite is still green. CI sets
45
+ `STARTBACK_SPEC_REQUIRE_BUNNY=1`, which turns "no broker" into a failure, so
46
+ that a broken service container cannot quietly take the coverage away.
47
+
48
+ ## Docker images
49
+
50
+ docker pull enspirit/startback:api # ruby 3.4
51
+ docker pull enspirit/startback:web # ruby 3.4, plus nodejs and yarn
52
+
53
+ The tags that name no ruby version -- `:api`, `:api-2.1.0`, `:api-2.1` -- are
54
+ built with `DEFAULT_MRI_VERSION`, currently **3.4**. Every ruby version listed
55
+ in `RELEASE_MRI_VERSIONS` is also reachable by name:
56
+
57
+ docker pull enspirit/startback:api-ruby4.0
58
+ docker pull enspirit/startback:api-2.1.0-ruby4.0
59
+
60
+ Both variables live at the bottom of the [Makefile](Makefile). Adding a ruby
61
+ version to the release matrix means listing it there and in the
62
+ `ruby-version` matrix of the tests and release-images workflows.
63
+
64
+ `make images` builds and pushes one ruby version (`MRI_VERSION`, defaulting to
65
+ `DEFAULT_MRI_VERSION`); `make images.all` walks the whole matrix, as the
66
+ release workflow does with one job per version.
data/UPGRADING.md CHANGED
@@ -1,5 +1,121 @@
1
1
  # Upgrading Startback
2
2
 
3
+ ## From 2.0.x to 2.1.0
4
+
5
+ **There is next to nothing to do.** Startback's API is unchanged and the
6
+ event bus upgrade needs no broker-side migration. One thing does move: the
7
+ un-suffixed docker tags go from Ruby 3.3 to Ruby 3.4. This section exists so
8
+ you know *why*, and so you can spot the one thing that might bite you later.
9
+
10
+ | | |
11
+ |---|---|
12
+ | Ruby | Unchanged, still `>= 3.2`. Ruby 4.0 is now supported and tested. |
13
+ | Docker images | `enspirit/startback:api` and `:web` move from Ruby 3.3 to **Ruby 3.4**. Ruby 4.0 is opt-in by name. |
14
+ | Event bus | Durable topology now, adopted automatically. **But see the RabbitMQ 4.3 wall below.** |
15
+
16
+ ---
17
+
18
+ ### The one thing to know: RabbitMQ 4.3
19
+
20
+ This is the only item here with a deadline, and it is not really about
21
+ Startback.
22
+
23
+ `queue_options` used to default to `{}`, declaring a *transient
24
+ non-exclusive* queue. RabbitMQ deprecated that and flips it to denied in 4.3:
25
+
26
+ | RabbitMQ | transient non-exclusive queues | Startback <= 2.0 bus |
27
+ |---|---|---|
28
+ | 4.0, 4.1, 4.2 | permitted | works |
29
+ | **4.3+** | **denied** | **`listen` receives nothing, then `Timeout::Error`** |
30
+
31
+ So on 4.2 or earlier nothing is on fire today -- but 4.3 is a wall you hit
32
+ whether or not you upgrade Startback. 2.1.0 is what gets you over it: the
33
+ exchange and queue are now declared `durable: true`.
34
+
35
+ ### Why the durable switch costs you nothing
36
+
37
+ AMQP refuses to redeclare an exchange or queue with different properties. On
38
+ a broker that has been up continuously since an older Startback declared its
39
+ topology, the new durable declaration is rejected with
40
+ `PRECONDITION_FAILED`. Startback now **adopts** what is already there rather
41
+ than failing on it, logging:
42
+
43
+ ```
44
+ Adopting an existing fanout whose properties differ from the requested ones.
45
+ It will be declared as requested after the next broker restart.
46
+ ```
47
+
48
+ So deploy in any order, with or without restarting the broker. There is
49
+ nothing to drain and nothing to delete: a transient queue holds no durable
50
+ state, and does not survive a broker restart in the first place. You become
51
+ durable by yourself the next time the broker restarts.
52
+
53
+ Restarting the broker before deploying gets you there immediately, but it is
54
+ an option, not a requirement.
55
+
56
+ Applications already passing their own `queue_options`/`fanout_options` are
57
+ unaffected: explicit options still win.
58
+
59
+ ### Two bus bugs fixed, in case you saw them
60
+
61
+ Both predate 2.1.0 and neither announced itself. If you have ever seen the
62
+ bus "just stop" until a restart, this is likely why:
63
+
64
+ * **A dead channel was cached forever.** A channel-level error closes the
65
+ channel, and the bus kept one per thread without checking it was still
66
+ open. One such error broke the bus for that thread permanently -- every
67
+ later `emit` failing with `cannot use a closed channel`, for *any* event
68
+ type. Since `emit` runs inside `stop_errors`, the application kept
69
+ returning 200s while dropping every event.
70
+
71
+ * **Declaring could unsubscribe your listeners.** A rejected declaration
72
+ closes the channel it happened on, which was the shared one carrying your
73
+ consumers. Topology is now probed on a scratch channel.
74
+
75
+ ### Bus listeners still receive a String
76
+
77
+ Not a change, but now documented and pinned by a spec, because it bites
78
+ people moving a listener between busses:
79
+
80
+ ```ruby
81
+ bus.listen("My::Event::Type", "my-processor") do |body|
82
+ # Bus::Memory::Async hands over a Startback::Event here.
83
+ # Bus::Bunny::Async hands over the raw JSON String.
84
+ event = Startback::Event.json(body, nil)
85
+ end
86
+ ```
87
+
88
+ ### Docker images, if you build on them
89
+
90
+ `enspirit/startback:api` and `:web` are built from `DEFAULT_MRI_VERSION`,
91
+ now **Ruby 3.4**. 2.0.0 published them from Ruby 3.3, so tracking those tags
92
+ moves you one ruby minor version -- not a major, and 3.3 reaches end of life
93
+ in March 2027. Ruby 4.0 stays opt-in, asked for by name:
94
+
95
+ ```dockerfile
96
+ FROM enspirit/startback:api-ruby4.0 # tracks 2.x on ruby 4.0
97
+ FROM enspirit/startback:api-2.1.0-ruby4.0 # pinned
98
+ ```
99
+
100
+ The `web` target now installs **nodejs 22** instead of 20, node 20 being end
101
+ of life since April 2026. Applications pinning a node version in their own
102
+ layer are unaffected.
103
+
104
+ **Moving your own application to Ruby 4.0** is a separate exercise, and worth
105
+ doing separately. `benchmark`, `logger` and `ostruct` stop being default gems
106
+ there: if your code requires them without declaring them, add them to your
107
+ Gemfile. Startback already declares all three for itself.
108
+
109
+ ### Checklist
110
+
111
+ - [ ] Nothing, unless you are heading for RabbitMQ 4.3 -- in which case 2.1.0
112
+ is what you need, and it is enough
113
+ - [ ] `:api` / `:web` move from Ruby 3.3 to 3.4. 2.1.0 publishes no Ruby 3.3
114
+ image: ask for `-ruby4.0` if you want 4.0, or stay on
115
+ `:api-2.0.0-ruby3.3` if you are not ready to leave 3.3
116
+
117
+ ---
118
+
3
119
  ## From 1.2.x to 2.0.0
4
120
 
5
121
  **Startback's own API has not changed.** Every class, require path, constructor
@@ -270,12 +386,14 @@ Applies to `Startback::Event::Bus::Bunny::Async` only.
270
386
 
271
387
  **Heads up on coverage:** Startback's test matrix has no RabbitMQ, so the Bunny
272
388
  bus is upgraded but *unverified by the suite*. If you use it, exercise it in a
273
- staging environment rather than trusting the green build.
389
+ staging environment rather than trusting the green build. *(Fixed in 2.1.0 --
390
+ and it found a RabbitMQ 4.3 incompatibility. See the 2.1.0 section above.)*
274
391
 
275
392
  **Not ready?** `gem 'bunny', '~> 2.14'`. Startback accepts `>= 2.14, < 4.0`.
276
393
 
277
394
  ---
278
395
 
396
+
279
397
  ## 10. webspicy must move to 1.x
280
398
 
281
399
  **You will see** `bundle install` fail outright:
@@ -5,14 +5,38 @@ 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
+ #
31
+ # Listeners **survive a broker restart**. Bunny recovers the topology
32
+ # it recorded, but not what was adopted passively: rebinding such a
33
+ # queue fails with NOT_FOUND and closes the channel its consumer lives
34
+ # on. Once Bunny reports the connection recovered, the bus therefore
35
+ # subscribes again every listener whose consumer did not come back.
36
+ # `alive?` tells whether every listener still consumes, which is what
37
+ # a liveness probe wants to ask: Bunny's own recovery may also never
38
+ # complete at all.
39
+ #
16
40
  # Examples:
17
41
  #
18
42
  # # Connects to RabbitMQ using all default options
@@ -46,10 +70,21 @@ module Startback
46
70
  connection_options: nil,
47
71
 
48
72
  # (optional) The options to use for the emitter/listener fanout
49
- fanout_options: {},
73
+ #
74
+ # Durable by default, so that the exchange and the bindings
75
+ # pointing at it survive a broker restart. A durable queue bound
76
+ # to a transient exchange would come back alone, with nothing
77
+ # routing to it.
78
+ fanout_options: { durable: true },
50
79
 
51
80
  # (optional) The options to use for the listener queue
52
- queue_options: {},
81
+ #
82
+ # Durable by default, because RabbitMQ 4 refuses transient
83
+ # non-exclusive queues: declaring one fails the channel, and
84
+ # `listen` never receives anything. Durability is also what a
85
+ # named processor queue wants -- events waiting in it outlive a
86
+ # broker restart rather than being dropped on the floor.
87
+ queue_options: { durable: true },
53
88
 
54
89
  # (optional) Default event factory to use, if any
55
90
  event_factory: nil,
@@ -74,15 +109,23 @@ module Startback
74
109
  def initialize(options = {})
75
110
  options = { url: options } if options.is_a?(String)
76
111
  @options = DEFAULT_OPTIONS.merge(options)
112
+ @topology = {}
113
+ @topology_lock = Mutex.new
114
+ @listeners = []
115
+ @listeners_lock = Mutex.new
77
116
  connect if @options[:autoconnect]
78
117
  end
79
118
  attr_reader :options
80
119
 
81
120
  def connect
82
121
  disconnect
122
+ # What the broker holds is only known for a given connection: a
123
+ # restart in between wipes every transient exchange and queue.
124
+ @topology_lock.synchronize { @topology = {} }
83
125
  conn = options[:connection_options] || options[:url]
84
126
  try_max_times(10) do
85
127
  @bunny = ::Bunny.new(conn)
128
+ @bunny.after_recovery_completed { recovery_completed(conn) }
86
129
  @bunny.start
87
130
  channel # make sure we already create the channel
88
131
  log(:info, {op: "#{self.class.name}#connect", op_data: conn}, options[:context])
@@ -101,12 +144,28 @@ module Startback
101
144
  @bunny && @bunny.connected?
102
145
  end
103
146
 
147
+ # Whether the bus is connected and every listener still consumes:
148
+ # its channel is open and the broker did not cancel its consumer.
149
+ # False is what a liveness probe should restart the process on.
150
+ def alive?
151
+ !!connected? && listeners.all?(&:alive?)
152
+ end
153
+
104
154
  def channel
105
155
  unless @bunny
106
156
  raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
107
157
  end
108
158
 
109
- Thread.current[CHANNEL_KEY] ||= @bunny.create_channel(
159
+ # A channel-level error closes the channel, and bunny does not
160
+ # reopen it. Since this one is cached per thread, a single such
161
+ # error used to leave the thread with a dead channel forever:
162
+ # every later emit failed with "cannot use a closed channel",
163
+ # whatever the event type, and `stop_errors` hid it. Dropping a
164
+ # closed channel here is what makes the bus recover on its own.
165
+ current = Thread.current[CHANNEL_KEY]
166
+ current = nil unless current.nil? || current.open?
167
+
168
+ Thread.current[CHANNEL_KEY] = current || @bunny.create_channel(
110
169
  nil,
111
170
  consumer_pool_size, # consumer_pool_size
112
171
  abort_on_exception? # consumer_pool_abort_on_exception
@@ -115,7 +174,7 @@ module Startback
115
174
 
116
175
  def emit(event)
117
176
  stop_errors(self, "emit", event.context) do
118
- fanout = channel.fanout(event.type.to_s, fanout_options)
177
+ fanout = declare(:fanout, event.type.to_s, fanout_options)
119
178
  fanout.publish(event.to_json)
120
179
  end
121
180
  end
@@ -123,17 +182,147 @@ module Startback
123
182
  def listen(type, processor = nil, listener = nil, &bl)
124
183
  raise ArgumentError, "A listener must be provided" unless listener || bl
125
184
 
126
- fanout = channel.fanout(type.to_s, fanout_options)
127
- queue = channel.queue((processor || "main").to_s, queue_options)
128
- queue.bind(fanout)
129
- queue.subscribe do |delivery_info, properties, body|
185
+ subscription = Subscription.new(type.to_s, (processor || "main").to_s, listener || bl)
186
+ subscribe(subscription)
187
+ @listeners_lock.synchronize { @listeners << subscription }
188
+ subscription.consumer
189
+ end
190
+
191
+ protected
192
+
193
+ # What `listen` was asked for, kept to subscribe again after a
194
+ # broker restart, along with the consumer currently serving it.
195
+ class Subscription < Struct.new(:type, :processor, :listener, :consumer)
196
+ def alive?
197
+ channel = consumer && consumer.channel
198
+ !!channel && channel.open? && channel.consumers.key?(consumer.consumer_tag)
199
+ end
200
+ end
201
+
202
+ def listeners
203
+ @listeners_lock.synchronize { @listeners.dup }
204
+ end
205
+
206
+ def subscribe(subscription)
207
+ fanout = declare(:fanout, subscription.type, fanout_options)
208
+ queue = declare(:queue, subscription.processor, queue_options)
209
+ # Bound by name on purpose: declaring the queue may have renewed
210
+ # the channel, in which case `fanout` belongs to a closed one.
211
+ queue.bind(fanout.name)
212
+ subscription.consumer = queue.subscribe do |delivery_info, properties, body|
130
213
  stop_errors(self, "listen") do
131
- (listener || bl).call(body)
214
+ subscription.listener.call(body)
132
215
  end
133
216
  end
134
217
  end
135
218
 
136
- protected
219
+ # Called by Bunny once it reconnected and replayed the topology it
220
+ # recorded, after the application's own `recovery_completed`
221
+ # callback, if any.
222
+ #
223
+ # What Bunny replayed may no longer match the broker -- objects
224
+ # adopted passively are not replayed at all -- so the topology is
225
+ # probed anew, and the listeners left without a consumer subscribe
226
+ # again. One that fails stays dead, and `alive?` reports it.
227
+ def recovery_completed(conn)
228
+ conn[:recovery_completed].call if conn.is_a?(Hash) && conn[:recovery_completed]
229
+
230
+ @topology_lock.synchronize { @topology = {} }
231
+ listeners.reject(&:alive?).each do |subscription|
232
+ renew_channel!
233
+ subscribe(subscription)
234
+ log(:info, {
235
+ op: "#{self.class.name}#recovery_completed",
236
+ op_data: { type: subscription.type, processor: subscription.processor, msg: "Subscribed again" }
237
+ }, options[:context])
238
+ rescue StandardError => ex
239
+ log(:error, {
240
+ op: "#{self.class.name}#recovery_completed",
241
+ op_data: { type: subscription.type, processor: subscription.processor },
242
+ error: ex
243
+ }, options[:context])
244
+ end
245
+ end
246
+
247
+ # Declares an exchange (`kind` = :fanout) or a queue (:queue) with
248
+ # `options`, adopting one that already exists with other properties
249
+ # instead of failing on it.
250
+ #
251
+ # Startback used to declare both transient. AMQP refuses to
252
+ # redeclare an object with different properties, so an application
253
+ # upgrading to the durable defaults would get the broker closing its
254
+ # channel with PRECONDITION_FAILED -- and, `emit` being wrapped in
255
+ # `stop_errors`, would silently stop emitting rather than crash.
256
+ #
257
+ # Requiring a coordinated broker restart to avoid that is a poor
258
+ # deal for something the upgrade gains nothing from, so adopt what
259
+ # is there: `passive: true` matches an existing object whatever its
260
+ # properties. Transient objects disappear at the next broker
261
+ # restart anyway, and the durable declaration then wins on its own,
262
+ # with nobody having had to do anything.
263
+ def declare(kind, name, options)
264
+ channel.public_send(kind, name, topology_options(kind, name, options))
265
+ rescue ::Bunny::PreconditionFailed, ::Bunny::NotFound
266
+ # The topology moved under us -- a broker restart took a transient
267
+ # object away, or another application redeclared it. Forget what
268
+ # was known of it and look again.
269
+ forget_topology(kind, name)
270
+ renew_channel!
271
+ channel.public_send(kind, name, topology_options(kind, name, options))
272
+ end
273
+
274
+ # The options that actually work for `name`: the requested ones,
275
+ # unless an incompatible object is already there, in which case
276
+ # `passive: true`, which matches whatever its properties are.
277
+ #
278
+ # Memoized, because `emit` declares the exchange on every call and
279
+ # the answer only changes across connections.
280
+ def topology_options(kind, name, requested)
281
+ key = [kind, name]
282
+ @topology_lock.synchronize do
283
+ return @topology[key] if @topology.key?(key)
284
+ end
285
+ probed = probe_topology(kind, name, requested)
286
+ @topology_lock.synchronize { @topology[key] = probed }
287
+ end
288
+
289
+ def forget_topology(kind, name)
290
+ @topology_lock.synchronize { @topology.delete([kind, name]) }
291
+ end
292
+
293
+ # Tries the requested options on a **scratch channel**, never on the
294
+ # one the bus works with. A rejected declaration is a channel-level
295
+ # error, and the broker closes the channel it happened on: probing
296
+ # on the shared channel would take down every consumer registered
297
+ # there, so emitting would silently unsubscribe the listeners.
298
+ def probe_topology(kind, name, requested)
299
+ scratch = @bunny.create_channel
300
+ begin
301
+ scratch.public_send(kind, name, requested)
302
+ requested
303
+ rescue ::Bunny::PreconditionFailed
304
+ log(:warn, {
305
+ op: "#{self.class.name}#declare",
306
+ op_data: {
307
+ kind: kind,
308
+ name: name,
309
+ msg: "Adopting an existing #{kind} whose properties differ " \
310
+ "from the requested ones. It will be declared as " \
311
+ "requested after the next broker restart."
312
+ }
313
+ }, self.options[:context])
314
+ { passive: true }
315
+ ensure
316
+ scratch.close if scratch.open?
317
+ end
318
+ end
319
+
320
+ # Forgets the current channel, which a channel-level error left
321
+ # closed, so that `channel` opens a fresh one.
322
+ def renew_channel!
323
+ Thread.current[CHANNEL_KEY] = nil
324
+ channel
325
+ end
137
326
 
138
327
  def consumer_pool_size
139
328
  options[:consumer_pool_size]
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 2
4
- MINOR = 0
5
- TINY = 0
4
+ MINOR = 1
5
+ TINY = 1
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
data/spec/spec_helper.rb CHANGED
@@ -12,6 +12,7 @@ require 'startback/audit'
12
12
  require 'startback/security'
13
13
  require 'rack/test'
14
14
  require 'ostruct'
15
+ require 'support/bunny_broker'
15
16
 
16
17
  module SpecHelpers
17
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
@@ -0,0 +1,395 @@
1
+ require 'spec_helper'
2
+ require 'startback/event/bus/bunny'
3
+
4
+ module Startback
5
+ class Event
6
+ describe Bus::Bunny::Async do
7
+
8
+ # Exchange and queue names are per-example, see BunnyBroker#unique:
9
+ # both are durable, so they outlive the example that created them.
10
+ let(:type) { BunnyBroker.unique("Spec::Event") }
11
+ let(:processor) { BunnyBroker.unique("spec-processor") }
12
+ let(:url) { BunnyBroker.url }
13
+
14
+ let(:bus) { Bus::Bunny::Async.new(url) }
15
+
16
+ # Busses an example connected, closed afterwards whatever happens, so
17
+ # that a failing example does not leak a connection into the next one.
18
+ let(:opened) { [] }
19
+
20
+ def connected_bus(options = {})
21
+ Bus::Bunny::Async.new({ url: url }.merge(options)).tap do |b|
22
+ opened << b
23
+ b.connect unless options[:autoconnect]
24
+ end
25
+ end
26
+
27
+ before do
28
+ BunnyBroker.skip_unless_available!(self)
29
+ end
30
+
31
+ after do
32
+ opened.each { |b| b.disconnect rescue nil }
33
+ BunnyBroker.delete_topology(exchanges: [type], queues: [processor, "main"])
34
+ end
35
+
36
+ describe "connecting" do
37
+
38
+ it 'is not connected before connect is called' do
39
+ expect(bus.connected?).to be_falsey
40
+ end
41
+
42
+ it 'connects and disconnects' do
43
+ b = connected_bus
44
+ expect(b.connected?).to eql(true)
45
+ b.disconnect
46
+ expect(b.connected?).to be_falsey
47
+ end
48
+
49
+ it 'connects at construction when autoconnect is set' do
50
+ b = connected_bus(autoconnect: true)
51
+ expect(b.connected?).to eql(true)
52
+ end
53
+
54
+ it 'takes a String as being the url' do
55
+ b = Bus::Bunny::Async.new(url)
56
+ opened << b
57
+ expect(b.options[:url]).to eql(url)
58
+ b.connect
59
+ expect(b.connected?).to eql(true)
60
+ end
61
+
62
+ it 'defaults the url to STARTBACK_BUS_BUNNY_ASYNC_URL' do
63
+ # DEFAULT_OPTIONS captures the variable at load time, so the class
64
+ # constant is the observable, not a re-read of ENV here.
65
+ expect(Bus::Bunny::Async::DEFAULT_OPTIONS[:url])
66
+ .to eql(ENV['STARTBACK_BUS_BUNNY_ASYNC_URL'])
67
+ end
68
+
69
+ it 'refuses to hand out a channel before connecting' do
70
+ expect {
71
+ bus.channel
72
+ }.to raise_error(Startback::Errors::Error, /connect your bus first/)
73
+ end
74
+
75
+ end
76
+
77
+ describe "emitting and listening" do
78
+
79
+ it 'round trips an event through the broker with default options' do
80
+ # Regression test for the defaults themselves: RabbitMQ 4 refuses
81
+ # transient non-exclusive queues, which is what queue_options used
82
+ # to ask for. This example fails with a Timeout::Error there.
83
+ b = connected_bus
84
+ seen = Queue.new
85
+ b.listen(type, processor) { |body| seen << body }
86
+ b.emit(Event.new(type, { id: 12 }))
87
+
88
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
89
+ expect(received).not_to be_nil
90
+ parsed = JSON.parse(received)
91
+ expect(parsed["type"]).to eql(type)
92
+ expect(parsed["data"]).to eql({ "id" => 12 })
93
+ end
94
+
95
+ it 'hands the listener the raw JSON String, not an Event' do
96
+ # Documents an asymmetry with Bus::Memory::Async, which hands over
97
+ # an Event instance. Listeners are not portable between the two.
98
+ b = connected_bus
99
+ seen = Queue.new
100
+ b.listen(type, processor) { |body| seen << body }
101
+ b.emit(Event.new(type, { id: 12 }))
102
+
103
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
104
+ expect(received).to be_a(String)
105
+ end
106
+
107
+ it 'declares the exchange and the queue durable' do
108
+ b = connected_bus
109
+ b.listen(type, processor) { |body| }
110
+
111
+ # Redeclaring passively tells us what the broker actually holds:
112
+ # a mismatch on durable would raise Bunny::PreconditionFailed.
113
+ expect {
114
+ ch = b.channel
115
+ ch.fanout(type, durable: true, passive: true)
116
+ ch.queue(processor, durable: true, passive: true)
117
+ }.not_to raise_error
118
+ end
119
+
120
+ it 'allows mixing Symbol vs. String for event type' do
121
+ b = connected_bus
122
+ seen = Queue.new
123
+ b.listen(type.to_sym, processor) { |body| seen << body }
124
+ b.emit(Event.new(type.to_sym, { id: 12 }))
125
+
126
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
127
+ expect(received).not_to be_nil
128
+ expect(JSON.parse(received)["type"]).to eql(type)
129
+ end
130
+
131
+ it 'fans an event out to every processor queue' do
132
+ other = BunnyBroker.unique("spec-other")
133
+ b = connected_bus
134
+ one, two = Queue.new, Queue.new
135
+ b.listen(type, processor) { |body| one << body }
136
+ b.listen(type, other) { |body| two << body }
137
+ b.emit(Event.new(type, { id: 12 }))
138
+
139
+ expect(BunnyBroker.wait_for { one.pop unless one.empty? }).not_to be_nil
140
+ expect(BunnyBroker.wait_for { two.pop unless two.empty? }).not_to be_nil
141
+
142
+ BunnyBroker.delete_topology(queues: [other])
143
+ end
144
+
145
+ it 'does not deliver an event to a queue bound to another type' do
146
+ other_type = BunnyBroker.unique("Spec::Other")
147
+ b = connected_bus
148
+ seen = Queue.new
149
+ b.listen(other_type, processor) { |body| seen << body }
150
+ b.emit(Event.new(type, { id: 12 }))
151
+
152
+ # Nothing should arrive. Give the broker a real chance to prove us
153
+ # wrong before concluding, hence a short but non-zero wait.
154
+ expect(BunnyBroker.wait_for(1) { seen.pop unless seen.empty? }).to be_nil
155
+
156
+ BunnyBroker.delete_topology(exchanges: [other_type])
157
+ end
158
+
159
+ it 'requires a listener' do
160
+ b = connected_bus
161
+ expect {
162
+ b.listen(type, processor)
163
+ }.to raise_error(ArgumentError, /listener must be provided/)
164
+ end
165
+
166
+ end
167
+
168
+ describe "adopting a pre-existing topology" do
169
+
170
+ # What an older Startback left on the broker: a transient exchange.
171
+ # This is the upgrade path, and it must not need a broker-side
172
+ # migration. Only the exchange, because a transient queue cannot be
173
+ # declared at all from RabbitMQ 4.3 on -- see the last example here.
174
+ def declare_legacy_exchange!
175
+ conn = ::Bunny.new(url, log_level: :fatal)
176
+ conn.start
177
+ conn.create_channel.fanout(type, {})
178
+ conn.close
179
+ end
180
+
181
+ it 'emits through a transient exchange instead of dropping the event' do
182
+ declare_legacy_exchange!
183
+ b = connected_bus
184
+
185
+ seen = Queue.new
186
+ b.listen(type, processor) { |body| seen << body }
187
+ b.emit(Event.new(type, { id: 12 }))
188
+
189
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
190
+ expect(received).not_to be_nil
191
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
192
+ end
193
+
194
+ it 'leaves the adopted exchange alone rather than redeclaring it' do
195
+ declare_legacy_exchange!
196
+ b = connected_bus
197
+ b.listen(type, processor) { |body| }
198
+
199
+ # Still transient: adopting means taking what is there. Declaring
200
+ # it transient again would raise had the bus turned it durable.
201
+ conn = ::Bunny.new(url, log_level: :fatal)
202
+ conn.start
203
+ expect {
204
+ conn.create_channel.fanout(type, durable: false)
205
+ }.not_to raise_error
206
+ conn.close
207
+ end
208
+
209
+ it 'keeps the channel usable after the conflict' do
210
+ # The conflict closes the channel. Before the bus learned to renew
211
+ # it, the closed one stayed cached per thread and every later emit
212
+ # failed with "cannot use a closed channel" -- silently, since
213
+ # `emit` runs inside stop_errors -- whatever the event type.
214
+ declare_legacy_exchange!
215
+ b = connected_bus
216
+ b.emit(Event.new(type, { id: 1 }))
217
+
218
+ expect(b.channel.open?).to eql(true)
219
+
220
+ other_type = BunnyBroker.unique("Spec::Unrelated")
221
+ other_proc = BunnyBroker.unique("spec-unrelated")
222
+ seen = Queue.new
223
+ b.listen(other_type, other_proc) { |body| seen << body }
224
+ b.emit(Event.new(other_type, { id: 2 }))
225
+
226
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
227
+ expect(received).not_to be_nil
228
+
229
+ BunnyBroker.delete_topology(exchanges: [other_type], queues: [other_proc])
230
+ end
231
+
232
+ it 'keeps delivering when exchange AND queue were both transient' do
233
+ # The true state an older Startback leaves behind, and the one that
234
+ # matters: adopting two objects means two channel renewals, so the
235
+ # consumer ends up on a different channel than the one the exchange
236
+ # was adopted on. Probing on the shared channel would then have
237
+ # `emit` close the consumer's channel -- events silently lost.
238
+ #
239
+ # Only reproducible where transient queues can still be declared,
240
+ # i.e. RabbitMQ <= 4.2, which is why CI runs a broker of that
241
+ # generation alongside the current one.
242
+ BunnyBroker.skip_unless_transient_queues!(self)
243
+
244
+ conn = ::Bunny.new(url, log_level: :fatal)
245
+ conn.start
246
+ ch = conn.create_channel
247
+ ch.queue(processor, {}).bind(ch.fanout(type, {}))
248
+ conn.close
249
+
250
+ b = connected_bus
251
+ seen = Queue.new
252
+ b.listen(type, processor) { |body| seen << body }
253
+ b.emit(Event.new(type, { id: 12 }))
254
+
255
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
256
+ expect(received).not_to be_nil
257
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
258
+ end
259
+
260
+ end
261
+
262
+ describe "staying alive" do
263
+
264
+ # What a broker restart leaves behind when the queue was adopted
265
+ # passively: Bunny does not replay it, rebinding fails, and the
266
+ # consumer is gone. Deleting the queue from elsewhere gets the same
267
+ # outcome -- the broker cancels the consumer -- without a restart.
268
+ def kill_consumer!
269
+ BunnyBroker.delete_topology(queues: [processor])
270
+ end
271
+
272
+ # Bunny calls this once it reconnected and replayed its topology.
273
+ def complete_recovery!(b)
274
+ b.instance_variable_get(:@bunny).send(:notify_of_recovery_completion)
275
+ end
276
+
277
+ it 'is not alive before connecting' do
278
+ expect(bus.alive?).to eql(false)
279
+ end
280
+
281
+ it 'is alive once connected and listening' do
282
+ b = connected_bus
283
+ b.listen(type, processor) { |body| }
284
+ expect(b.alive?).to eql(true)
285
+ end
286
+
287
+ it 'is not alive once disconnected' do
288
+ b = connected_bus
289
+ b.listen(type, processor) { |body| }
290
+ b.disconnect
291
+ expect(b.alive?).to eql(false)
292
+ end
293
+
294
+ it 'is not alive when a consumer is gone' do
295
+ b = connected_bus
296
+ b.listen(type, processor) { |body| }
297
+ kill_consumer!
298
+
299
+ expect(BunnyBroker.wait_for { !b.alive? }).to eql(true)
300
+ end
301
+
302
+ it 'subscribes dead listeners again once a recovery completed' do
303
+ b = connected_bus
304
+ seen = Queue.new
305
+ b.listen(type, processor) { |body| seen << body }
306
+ kill_consumer!
307
+ BunnyBroker.wait_for { !b.alive? }
308
+
309
+ complete_recovery!(b)
310
+
311
+ expect(b.alive?).to eql(true)
312
+ b.emit(Event.new(type, { id: 12 }))
313
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
314
+ expect(received).not_to be_nil
315
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
316
+ end
317
+
318
+ it 'declares the topology as requested when subscribing again' do
319
+ # An exchange adopted passively before the restart is gone after
320
+ # it: the durable declaration must win then, not the memoized
321
+ # `passive: true`, which would fail with NOT_FOUND.
322
+ b = connected_bus
323
+ b.listen(type, processor) { |body| }
324
+ b.instance_variable_get(:@topology)[[:fanout, type]] = { passive: true }
325
+ BunnyBroker.delete_topology(exchanges: [type], queues: [processor])
326
+ BunnyBroker.wait_for { !b.alive? }
327
+
328
+ complete_recovery!(b)
329
+
330
+ expect(b.alive?).to eql(true)
331
+ expect {
332
+ ch = b.channel
333
+ ch.fanout(type, durable: true, passive: true)
334
+ ch.queue(processor, durable: true, passive: true)
335
+ }.not_to raise_error
336
+ end
337
+
338
+ it 'leaves live listeners alone' do
339
+ b = connected_bus
340
+ consumer = b.listen(type, processor) { |body| }
341
+
342
+ complete_recovery!(b)
343
+
344
+ expect(b.send(:listeners).first.consumer).to equal(consumer)
345
+ end
346
+
347
+ it "still calls the application's own recovery_completed" do
348
+ called = Queue.new
349
+ b = connected_bus(connection_options: ::Bunny::Session.parse_uri(url).merge(
350
+ recovery_completed: -> { called << true }
351
+ ))
352
+ b.listen(type, processor) { |body| }
353
+
354
+ complete_recovery!(b)
355
+
356
+ expect(called.empty?).to eql(false)
357
+ end
358
+
359
+ end
360
+
361
+ describe "the asynchronous contract" do
362
+
363
+ it 'hides emit errors from the emitter' do
364
+ # An async bus MUST NOT let errors reach the emitter. Emitting
365
+ # without connecting raises inside, and must stay inside.
366
+ expect {
367
+ bus.emit(Event.new(type, { id: 12 }))
368
+ }.not_to raise_error
369
+ end
370
+
371
+ it 'keeps consuming after a listener raised' do
372
+ b = connected_bus
373
+ seen = Queue.new
374
+ calls = Queue.new
375
+ b.listen(type, processor) do |body|
376
+ calls << body
377
+ raise "listener blew up" if JSON.parse(body)["data"]["id"] == 1
378
+
379
+ seen << body
380
+ end
381
+
382
+ b.emit(Event.new(type, { id: 1 }))
383
+ expect(BunnyBroker.wait_for { calls.pop unless calls.empty? }).not_to be_nil
384
+
385
+ b.emit(Event.new(type, { id: 2 }))
386
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
387
+ expect(received).not_to be_nil
388
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 2 })
389
+ end
390
+
391
+ end
392
+
393
+ end
394
+ end
395
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -533,6 +533,7 @@ files:
533
533
  - lib/startback/web/prometheus.rb
534
534
  - lib/startback/web/shield.rb
535
535
  - spec/spec_helper.rb
536
+ - spec/support/bunny_broker.rb
536
537
  - spec/unit/audit/ext/test_context.rb
537
538
  - spec/unit/audit/test_middleware.rb
538
539
  - spec/unit/audit/test_prometheus.rb
@@ -546,6 +547,7 @@ files:
546
547
  - spec/unit/context/test_middleware.rb
547
548
  - spec/unit/context/test_with_world.rb
548
549
  - spec/unit/context/test_world.rb
550
+ - spec/unit/event/bus/bunny/test_async.rb
549
551
  - spec/unit/event/bus/memory/test_async.rb
550
552
  - spec/unit/event/bus/memory/test_sync.rb
551
553
  - spec/unit/security/test_rate_limiter.rb