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 +4 -4
- data/CHANGELOG.md +140 -1
- data/README.md +42 -0
- data/UPGRADING.md +119 -1
- data/lib/startback/event/bus/bunny/async.rb +131 -9
- data/lib/startback/version.rb +1 -1
- data/spec/spec_helper.rb +1 -0
- data/spec/support/bunny_broker.rb +145 -0
- data/spec/unit/event/bus/bunny/test_async.rb +296 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 844c222d0b34ee64835caafe69e4a52b0fad2176653b007208fb16feba998556
|
|
4
|
+
data.tar.gz: c7a8ff0dd59aa367d4de6fdf4f23c4c916ac52e98e935316ca267af834ef7655
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
9
|
-
# yourself: it is NOT a startback official dependency).
|
|
8
|
+
# and using the 'bunny' gem.
|
|
10
9
|
#
|
|
11
10
|
# This bus implementation emits events by dumping them to RabbitMQ using
|
|
12
11
|
# the event type as exchange name. Listeners may use the `processor`
|
|
13
12
|
# parameter to specify the queue name ; otherwise a default "main" queue
|
|
14
13
|
# is used.
|
|
15
14
|
#
|
|
15
|
+
# WARNING: unlike Bus::Memory::Async, which hands listeners an Event
|
|
16
|
+
# instance, this bus hands them the **raw JSON String** read off the
|
|
17
|
+
# queue. A listener moved from the memory bus to this one therefore
|
|
18
|
+
# receives something else, silently. Parse it yourself, e.g. with
|
|
19
|
+
# `Startback::Event.json(body, context)`.
|
|
20
|
+
#
|
|
21
|
+
# The exchange and queue are declared **durable** by default. RabbitMQ
|
|
22
|
+
# refuses transient non-exclusive queues from 4.3 on, so the previous
|
|
23
|
+
# defaults stop working there entirely.
|
|
24
|
+
#
|
|
25
|
+
# An exchange or queue that already exists with other properties -- one
|
|
26
|
+
# an older Startback declared transient -- is **adopted as it stands**
|
|
27
|
+
# rather than redeclared, so upgrading needs no broker-side migration.
|
|
28
|
+
# A warning is logged, and the durable declaration takes effect on its
|
|
29
|
+
# own at the next broker restart, transient objects not surviving one.
|
|
30
|
+
#
|
|
16
31
|
# Examples:
|
|
17
32
|
#
|
|
18
33
|
# # Connects to RabbitMQ using all default options
|
|
@@ -46,10 +61,21 @@ module Startback
|
|
|
46
61
|
connection_options: nil,
|
|
47
62
|
|
|
48
63
|
# (optional) The options to use for the emitter/listener fanout
|
|
49
|
-
|
|
64
|
+
#
|
|
65
|
+
# Durable by default, so that the exchange and the bindings
|
|
66
|
+
# pointing at it survive a broker restart. A durable queue bound
|
|
67
|
+
# to a transient exchange would come back alone, with nothing
|
|
68
|
+
# routing to it.
|
|
69
|
+
fanout_options: { durable: true },
|
|
50
70
|
|
|
51
71
|
# (optional) The options to use for the listener queue
|
|
52
|
-
|
|
72
|
+
#
|
|
73
|
+
# Durable by default, because RabbitMQ 4 refuses transient
|
|
74
|
+
# non-exclusive queues: declaring one fails the channel, and
|
|
75
|
+
# `listen` never receives anything. Durability is also what a
|
|
76
|
+
# named processor queue wants -- events waiting in it outlive a
|
|
77
|
+
# broker restart rather than being dropped on the floor.
|
|
78
|
+
queue_options: { durable: true },
|
|
53
79
|
|
|
54
80
|
# (optional) Default event factory to use, if any
|
|
55
81
|
event_factory: nil,
|
|
@@ -74,12 +100,17 @@ module Startback
|
|
|
74
100
|
def initialize(options = {})
|
|
75
101
|
options = { url: options } if options.is_a?(String)
|
|
76
102
|
@options = DEFAULT_OPTIONS.merge(options)
|
|
103
|
+
@topology = {}
|
|
104
|
+
@topology_lock = Mutex.new
|
|
77
105
|
connect if @options[:autoconnect]
|
|
78
106
|
end
|
|
79
107
|
attr_reader :options
|
|
80
108
|
|
|
81
109
|
def connect
|
|
82
110
|
disconnect
|
|
111
|
+
# What the broker holds is only known for a given connection: a
|
|
112
|
+
# restart in between wipes every transient exchange and queue.
|
|
113
|
+
@topology_lock.synchronize { @topology = {} }
|
|
83
114
|
conn = options[:connection_options] || options[:url]
|
|
84
115
|
try_max_times(10) do
|
|
85
116
|
@bunny = ::Bunny.new(conn)
|
|
@@ -106,7 +137,16 @@ module Startback
|
|
|
106
137
|
raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
|
|
107
138
|
end
|
|
108
139
|
|
|
109
|
-
|
|
140
|
+
# A channel-level error closes the channel, and bunny does not
|
|
141
|
+
# reopen it. Since this one is cached per thread, a single such
|
|
142
|
+
# error used to leave the thread with a dead channel forever:
|
|
143
|
+
# every later emit failed with "cannot use a closed channel",
|
|
144
|
+
# whatever the event type, and `stop_errors` hid it. Dropping a
|
|
145
|
+
# closed channel here is what makes the bus recover on its own.
|
|
146
|
+
current = Thread.current[CHANNEL_KEY]
|
|
147
|
+
current = nil unless current.nil? || current.open?
|
|
148
|
+
|
|
149
|
+
Thread.current[CHANNEL_KEY] = current || @bunny.create_channel(
|
|
110
150
|
nil,
|
|
111
151
|
consumer_pool_size, # consumer_pool_size
|
|
112
152
|
abort_on_exception? # consumer_pool_abort_on_exception
|
|
@@ -115,7 +155,7 @@ module Startback
|
|
|
115
155
|
|
|
116
156
|
def emit(event)
|
|
117
157
|
stop_errors(self, "emit", event.context) do
|
|
118
|
-
fanout =
|
|
158
|
+
fanout = declare(:fanout, event.type.to_s, fanout_options)
|
|
119
159
|
fanout.publish(event.to_json)
|
|
120
160
|
end
|
|
121
161
|
end
|
|
@@ -123,9 +163,11 @@ module Startback
|
|
|
123
163
|
def listen(type, processor = nil, listener = nil, &bl)
|
|
124
164
|
raise ArgumentError, "A listener must be provided" unless listener || bl
|
|
125
165
|
|
|
126
|
-
fanout =
|
|
127
|
-
queue =
|
|
128
|
-
queue
|
|
166
|
+
fanout = declare(:fanout, type.to_s, fanout_options)
|
|
167
|
+
queue = declare(:queue, (processor || "main").to_s, queue_options)
|
|
168
|
+
# Bound by name on purpose: declaring the queue may have renewed
|
|
169
|
+
# the channel, in which case `fanout` belongs to a closed one.
|
|
170
|
+
queue.bind(fanout.name)
|
|
129
171
|
queue.subscribe do |delivery_info, properties, body|
|
|
130
172
|
stop_errors(self, "listen") do
|
|
131
173
|
(listener || bl).call(body)
|
|
@@ -135,6 +177,86 @@ module Startback
|
|
|
135
177
|
|
|
136
178
|
protected
|
|
137
179
|
|
|
180
|
+
# Declares an exchange (`kind` = :fanout) or a queue (:queue) with
|
|
181
|
+
# `options`, adopting one that already exists with other properties
|
|
182
|
+
# instead of failing on it.
|
|
183
|
+
#
|
|
184
|
+
# Startback used to declare both transient. AMQP refuses to
|
|
185
|
+
# redeclare an object with different properties, so an application
|
|
186
|
+
# upgrading to the durable defaults would get the broker closing its
|
|
187
|
+
# channel with PRECONDITION_FAILED -- and, `emit` being wrapped in
|
|
188
|
+
# `stop_errors`, would silently stop emitting rather than crash.
|
|
189
|
+
#
|
|
190
|
+
# Requiring a coordinated broker restart to avoid that is a poor
|
|
191
|
+
# deal for something the upgrade gains nothing from, so adopt what
|
|
192
|
+
# is there: `passive: true` matches an existing object whatever its
|
|
193
|
+
# properties. Transient objects disappear at the next broker
|
|
194
|
+
# restart anyway, and the durable declaration then wins on its own,
|
|
195
|
+
# with nobody having had to do anything.
|
|
196
|
+
def declare(kind, name, options)
|
|
197
|
+
channel.public_send(kind, name, topology_options(kind, name, options))
|
|
198
|
+
rescue ::Bunny::PreconditionFailed, ::Bunny::NotFound
|
|
199
|
+
# The topology moved under us -- a broker restart took a transient
|
|
200
|
+
# object away, or another application redeclared it. Forget what
|
|
201
|
+
# was known of it and look again.
|
|
202
|
+
forget_topology(kind, name)
|
|
203
|
+
renew_channel!
|
|
204
|
+
channel.public_send(kind, name, topology_options(kind, name, options))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The options that actually work for `name`: the requested ones,
|
|
208
|
+
# unless an incompatible object is already there, in which case
|
|
209
|
+
# `passive: true`, which matches whatever its properties are.
|
|
210
|
+
#
|
|
211
|
+
# Memoized, because `emit` declares the exchange on every call and
|
|
212
|
+
# the answer only changes across connections.
|
|
213
|
+
def topology_options(kind, name, requested)
|
|
214
|
+
key = [kind, name]
|
|
215
|
+
@topology_lock.synchronize do
|
|
216
|
+
return @topology[key] if @topology.key?(key)
|
|
217
|
+
end
|
|
218
|
+
probed = probe_topology(kind, name, requested)
|
|
219
|
+
@topology_lock.synchronize { @topology[key] = probed }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def forget_topology(kind, name)
|
|
223
|
+
@topology_lock.synchronize { @topology.delete([kind, name]) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Tries the requested options on a **scratch channel**, never on the
|
|
227
|
+
# one the bus works with. A rejected declaration is a channel-level
|
|
228
|
+
# error, and the broker closes the channel it happened on: probing
|
|
229
|
+
# on the shared channel would take down every consumer registered
|
|
230
|
+
# there, so emitting would silently unsubscribe the listeners.
|
|
231
|
+
def probe_topology(kind, name, requested)
|
|
232
|
+
scratch = @bunny.create_channel
|
|
233
|
+
begin
|
|
234
|
+
scratch.public_send(kind, name, requested)
|
|
235
|
+
requested
|
|
236
|
+
rescue ::Bunny::PreconditionFailed
|
|
237
|
+
log(:warn, {
|
|
238
|
+
op: "#{self.class.name}#declare",
|
|
239
|
+
op_data: {
|
|
240
|
+
kind: kind,
|
|
241
|
+
name: name,
|
|
242
|
+
msg: "Adopting an existing #{kind} whose properties differ " \
|
|
243
|
+
"from the requested ones. It will be declared as " \
|
|
244
|
+
"requested after the next broker restart."
|
|
245
|
+
}
|
|
246
|
+
}, self.options[:context])
|
|
247
|
+
{ passive: true }
|
|
248
|
+
ensure
|
|
249
|
+
scratch.close if scratch.open?
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Forgets the current channel, which a channel-level error left
|
|
254
|
+
# closed, so that `channel` opens a fresh one.
|
|
255
|
+
def renew_channel!
|
|
256
|
+
Thread.current[CHANNEL_KEY] = nil
|
|
257
|
+
channel
|
|
258
|
+
end
|
|
259
|
+
|
|
138
260
|
def consumer_pool_size
|
|
139
261
|
options[:consumer_pool_size]
|
|
140
262
|
end
|
data/lib/startback/version.rb
CHANGED
data/spec/spec_helper.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|