startback 2.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74d6c4214ad33ba472543754def2abcda5d8b4bb00b632ff7521c7938c1bb9e5
4
- data.tar.gz: f5a3e38d97b24cfc6081e4f2dbc6939bbc3ee98f216522b85d88a82e3214ebc5
3
+ metadata.gz: 844c222d0b34ee64835caafe69e4a52b0fad2176653b007208fb16feba998556
4
+ data.tar.gz: c7a8ff0dd59aa367d4de6fdf4f23c4c916ac52e98e935316ca267af834ef7655
5
5
  SHA512:
6
- metadata.gz: 3c817bcfe4072d5a5b7a529e58d9cefde85afb6306fd4c173e5b478a2b5fb56096a30ca5ae1ac18fcb56dfd6ed7102eb2a55fe818da4aefa5739800078b24b3c
7
- data.tar.gz: b48bb2f32f7fb30be9359fe6758042574099a043abf4e9a82f2ec235b5e3e25b9241b2964effb8ab95fdb47943fede69a255aa0d5fc45bf324aa858497c28085
6
+ metadata.gz: e928708f893554970de832c1b0c1f1235134614f0fe7e6b82ed8208502f423c6f911d0ad3898f3ddd8185d0bdc85634d03ec40e9b169164100e64c72b1ca824e
7
+ data.tar.gz: ab333a49176eda2128b6cc1c502135be60c4cbc9479f05afef983186452f412c9c388f12cd2cece970e3da5a2a39da05e24dd7c2793f5d57e53d2365d90847d0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,141 @@
1
+ ## 2.1.0 - 2026-09-30
2
+
3
+ Ruby 4.0 support, docker images for several ruby versions, and the Bunny
4
+ event bus finally under test -- which is how two silent bugs and a RabbitMQ
5
+ 4.3 incompatibility came to light.
6
+
7
+ **Almost nothing here asks anything of you.** Startback's API is unchanged
8
+ and the bus upgrade needs no broker-side migration. The one thing that does
9
+ move: the un-suffixed docker tags go from Ruby 3.3 to Ruby 3.4, a minor
10
+ bump. The details are below because the *reasons* matter, not because there
11
+ is much to do.
12
+
13
+ ### Ruby 4.0 is supported
14
+
15
+ The test grid now runs **Ruby 3.2, 3.3, 3.4 and 4.0** -- the whole range
16
+ `required_ruby_version` allows, so the floor is a tested claim rather than an
17
+ assumed one. Startback needed no source change for 4.0: the gem,
18
+ `startback-jobs`, `startback-web` and the example application's webspicy
19
+ specs are green on all four, with no deprecation warning from Startback's own
20
+ code.
21
+
22
+ `required_ruby_version` stays `>= 3.2`.
23
+
24
+ ### Docker images are released for Ruby 3.4 and 4.0
25
+
26
+ Images are built for each ruby version of the release matrix. The tags that
27
+ name no ruby version follow `DEFAULT_MRI_VERSION`, which is **Ruby 3.4**:
28
+ 2.0.0 published them from Ruby 3.3, so `enspirit/startback:api` and `:web`
29
+ move by one minor version here, and stay put from now on.
30
+
31
+ | Tag | Ruby |
32
+ |---|---|
33
+ | `enspirit/startback:api`, `:api-2.1.0`, `:api-2.1` | 3.4 (was 3.3 in 2.0.0) |
34
+ | `enspirit/startback:api-ruby3.4`, `:api-2.1.0-ruby3.4`, `:api-2.1-ruby3.4` | 3.4 |
35
+ | `enspirit/startback:api-ruby4.0`, `:api-2.1.0-ruby4.0`, `:api-2.1-ruby4.0` | 4.0 |
36
+
37
+ Same for the `web` target. **No Ruby 3.3 image is published any more**: an
38
+ application pinned to `:api-ruby3.3` stays at 2.0.0 and should move to
39
+ `:api-ruby3.4`. Only `DEFAULT_MRI_VERSION` in the Makefile publishes the
40
+ un-suffixed tags, so adding a ruby version to the matrix can
41
+ never change what `:api` means depending on which release job finished last.
42
+ `make images.all` walks the whole matrix the way the release workflow does.
43
+
44
+ The `web` image moves from **nodejs 20 to nodejs 22**, node 20 having reached
45
+ end of life in April 2026.
46
+
47
+ ### The Bunny event bus is now covered by the test suite
48
+
49
+ It was the one part of Startback with no automated coverage, on the grounds
50
+ that the grid had no broker. It now has one, and 19 specs that talk to it for
51
+ real -- mocking bunny would only assert that Startback calls the methods
52
+ Startback calls. They cover connecting, autoconnect, the emit/listen round
53
+ trip, fanout across processor queues, type isolation, adoption of a
54
+ pre-existing topology, and the asynchronous contract that emit errors must
55
+ not reach the emitter.
56
+
57
+ Without a broker those specs skip, so `make tests` stays green for a
58
+ contributor without docker; `make rabbitmq.up` starts one through the new
59
+ `docker-compose.yml`. CI sets `STARTBACK_SPEC_REQUIRE_BUNNY=1`, which turns
60
+ "no broker" into a failure, because otherwise a broken service container
61
+ would take the coverage away without anything turning red.
62
+
63
+ ### The bus stops working on RabbitMQ 4.3, and now does not
64
+
65
+ `queue_options` defaulted to `{}`, which declares a *transient non-exclusive*
66
+ queue. RabbitMQ deprecated that, and flips it from `permitted_by_default` to
67
+ `denied_by_default` in 4.3:
68
+
69
+ | RabbitMQ | transient non-exclusive queues | Startback <= 2.0 bus |
70
+ |---|---|---|
71
+ | 4.0, 4.1, 4.2 | permitted | works |
72
+ | **4.3+** | **denied** | **`listen` receives nothing, then `Timeout::Error`** |
73
+
74
+ The defaults are now durable for both the exchange and the queue, which is
75
+ also what a named processor queue wants: events waiting in it outlive a
76
+ broker restart instead of being dropped, and a durable queue bound to a
77
+ transient exchange would come back after a restart with nothing routing to
78
+ it.
79
+
80
+ ```ruby
81
+ fanout_options: { durable: true },
82
+ queue_options: { durable: true },
83
+ ```
84
+
85
+ **No migration is required, deliberately.** AMQP refuses to redeclare an
86
+ object with different properties, so on a broker up since an older Startback
87
+ declared its topology the durable declaration is rejected with
88
+ `PRECONDITION_FAILED`. Rather than make that your problem, the bus *adopts*
89
+ whatever is already there (`passive: true` matches an existing object
90
+ whatever its properties) and logs a warning. Transient objects do not survive
91
+ a broker restart, so the durable declaration takes over by itself at the next
92
+ one, with nobody having done anything.
93
+
94
+ Left alone that would have been a nasty upgrade: `emit` runs inside
95
+ `stop_errors`, so the rejection was swallowed and the application went on
96
+ returning 200s while silently dropping every event.
97
+
98
+ Applications already passing their own `queue_options`/`fanout_options` are
99
+ unaffected: explicit options still win.
100
+
101
+ ### Two silent bunny bugs fixed
102
+
103
+ Both predate this release and neither announced itself:
104
+
105
+ * **A dead channel was cached forever.** A channel-level error closes the
106
+ channel, and the bus kept one per thread without checking it was still
107
+ open. One such error therefore broke the bus for that thread permanently --
108
+ every later `emit` failing with `cannot use a closed channel`, for *any*
109
+ event type, swallowed by `stop_errors`. The channel is now renewed when
110
+ found closed.
111
+
112
+ * **Declaring could unsubscribe your listeners.** A rejected declaration
113
+ closes the channel it happened on, which was the shared one carrying the
114
+ consumers. Topology is now probed on a scratch channel, memoized per
115
+ connection so `emit` does not pay for it on every call.
116
+
117
+ ### Known wart, documented rather than fixed
118
+
119
+ A Bunny listener receives the raw JSON `String` off the queue, where a
120
+ `Bus::Memory::Async` listener receives a `Startback::Event`. Listeners are
121
+ not portable between the two busses. The bus even carries a `factor_event`
122
+ method for this, defined and never called. A spec pins the current
123
+ behaviour; changing it would break every existing listener.
124
+
125
+ ### Other changes
126
+
127
+ * CI housekeeping: `actions/checkout` v2/v3 -> v4, `actions/setup-node` v3
128
+ -> v4 on nodejs 22 (14 was long end of life, and it is the javascript
129
+ runtime `startback-web`'s sprockets specs need), `docker/login-action` v1
130
+ -> v3. The image release workflow reads the version to tag from
131
+ `github.ref_name` rather than `git describe --contains`, which silently
132
+ yielded nothing on a shallow checkout and downgraded a release to
133
+ unversioned tags.
134
+
135
+ * The 2.0.0 notes said `benchmark`, `json`, `logger` and `ostruct` stop being
136
+ default gems "in Ruby 3.5". That release became 4.0, and `json` is still a
137
+ default gem there. Corrected in place.
138
+
1
139
  ## 2.0.0 - 2026-09-29
2
140
 
3
141
  Major dependency upgrade. Sinatra 4 (hence Rack 3) is now required, and the
@@ -130,7 +268,7 @@ however resolve to the newest of each, and each has its own breaking changes:
130
268
 
131
269
  * `benchmark`, `json`, `logger` and `ostruct` are now explicit runtime
132
270
  dependencies. They are required by `lib/startback.rb` and stop being default
133
- gems in Ruby 3.5.
271
+ gems in Ruby 4.0 -- `json` excepted, which is still one there.
134
272
 
135
273
  * The example application and both contrib gems moved to webspicy 1.0, whose
136
274
  own ranges are what makes finitio 1.0, http 6 and rack-robustness 2.0
@@ -144,6 +282,7 @@ however resolve to the newest of each, and each has its own breaking changes:
144
282
  RabbitMQ — so bunny 3 is upgraded but unverified by the suite. Likewise,
145
283
  `http`, `jwt`, `puma`, `nokogiri`, `tzinfo`, `i18n` and `mustache` are never
146
284
  loaded by Startback, so the suite says nothing about their new majors.
285
+ *(The bus is covered as of 2.1.0.)*
147
286
 
148
287
  * Applications testing with webspicy must move to its 1.x line. Every 0.27.x
149
288
  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,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
@@ -1,7 +1,7 @@
1
1
  module Startback
2
2
  module Version
3
3
  MAJOR = 2
4
- MINOR = 0
4
+ MINOR = 1
5
5
  TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
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,296 @@
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 "the asynchronous contract" do
263
+
264
+ it 'hides emit errors from the emitter' do
265
+ # An async bus MUST NOT let errors reach the emitter. Emitting
266
+ # without connecting raises inside, and must stay inside.
267
+ expect {
268
+ bus.emit(Event.new(type, { id: 12 }))
269
+ }.not_to raise_error
270
+ end
271
+
272
+ it 'keeps consuming after a listener raised' do
273
+ b = connected_bus
274
+ seen = Queue.new
275
+ calls = Queue.new
276
+ b.listen(type, processor) do |body|
277
+ calls << body
278
+ raise "listener blew up" if JSON.parse(body)["data"]["id"] == 1
279
+
280
+ seen << body
281
+ end
282
+
283
+ b.emit(Event.new(type, { id: 1 }))
284
+ expect(BunnyBroker.wait_for { calls.pop unless calls.empty? }).not_to be_nil
285
+
286
+ b.emit(Event.new(type, { id: 2 }))
287
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
288
+ expect(received).not_to be_nil
289
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 2 })
290
+ end
291
+
292
+ end
293
+
294
+ end
295
+ end
296
+ 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.0
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-09-30 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