typed_bus 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.github/workflows/deploy-yard-docs.yml +52 -0
- data/.irbrc +10 -0
- data/CHANGELOG.md +19 -0
- data/COMMITS.md +196 -0
- data/README.md +416 -0
- data/Rakefile +12 -0
- data/docs/api/channel.md +94 -0
- data/docs/api/configuration.md +90 -0
- data/docs/api/dead-letter-queue.md +52 -0
- data/docs/api/delivery-tracker.md +66 -0
- data/docs/api/delivery.md +73 -0
- data/docs/api/index.md +45 -0
- data/docs/api/message-bus.md +101 -0
- data/docs/api/stats.md +50 -0
- data/docs/architecture/concurrency-model.md +38 -0
- data/docs/architecture/configuration-cascade.md +76 -0
- data/docs/architecture/index.md +7 -0
- data/docs/architecture/publish-flow.md +67 -0
- data/docs/assets/css/custom.css +11 -0
- data/docs/assets/images/typed_bus.gif +0 -0
- data/docs/assets/images/typed_bus.jpg +0 -0
- data/docs/assets/images/typed_bus.mp4 +0 -0
- data/docs/concepts.md +47 -0
- data/docs/examples/index.md +37 -0
- data/docs/getting-started/configuration.md +87 -0
- data/docs/getting-started/index.md +7 -0
- data/docs/getting-started/installation.md +33 -0
- data/docs/getting-started/quick-start.md +51 -0
- data/docs/guides/adaptive-throttling.md +76 -0
- data/docs/guides/backpressure.md +58 -0
- data/docs/guides/dead-letter-queues.md +78 -0
- data/docs/guides/delivery-and-ack.md +89 -0
- data/docs/guides/error-handling.md +91 -0
- data/docs/guides/graceful-shutdown.md +75 -0
- data/docs/guides/index.md +13 -0
- data/docs/guides/logging.md +93 -0
- data/docs/guides/stats-and-monitoring.md +75 -0
- data/docs/guides/typed-channels.md +63 -0
- data/docs/index.md +56 -0
- data/examples/00_config.rb +156 -0
- data/examples/01_basic_usage.rb +26 -0
- data/examples/02_typed_channels.rb +41 -0
- data/examples/03_multiple_subscribers.rb +44 -0
- data/examples/04_nack_and_dead_letters.rb +47 -0
- data/examples/05_delivery_timeout.rb +44 -0
- data/examples/06_backpressure.rb +32 -0
- data/examples/07_message_bus.rb +55 -0
- data/examples/08_error_handling.rb +82 -0
- data/examples/09_stats_and_monitoring.rb +71 -0
- data/examples/10_concurrent_pipeline.rb +116 -0
- data/examples/11_logging_basic.rb +60 -0
- data/examples/12_logging_failures.rb +65 -0
- data/examples/13_logging_backpressure.rb +56 -0
- data/examples/14_adaptive_throttling.rb +101 -0
- data/examples/README.md +27 -0
- data/examples/temp.md +95 -0
- data/lib/typed_bus/channel.rb +261 -0
- data/lib/typed_bus/configuration.rb +72 -0
- data/lib/typed_bus/dead_letter_queue.rb +65 -0
- data/lib/typed_bus/delivery.rb +80 -0
- data/lib/typed_bus/delivery_tracker.rb +107 -0
- data/lib/typed_bus/message_bus.rb +164 -0
- data/lib/typed_bus/stats.rb +38 -0
- data/lib/typed_bus/version.rb +5 -0
- data/lib/typed_bus.rb +60 -0
- data/mkdocs.yml +183 -0
- metadata +129 -0
data/README.md
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# TypedBus
|
|
2
|
+
<em>Async pub/sub with typed channels and ACK-based delivery</em>
|
|
3
|
+
|
|
4
|
+
<table>
|
|
5
|
+
<tr>
|
|
6
|
+
<td width="50%" align="center" valign="top">
|
|
7
|
+
<img src="docs/assets/images/typed_bus.gif" alt="TypedBus" width="438"><br>
|
|
8
|
+
<a href="https://madbomber.github.io/typed_bus">Comprehensive Documentation</a>
|
|
9
|
+
</td>
|
|
10
|
+
<td width="50%" valign="top">
|
|
11
|
+
TypedBus provides named, optionally typed pub/sub channels with explicit ACK/NACK delivery, dead letter queues, backpressure, and adaptive throttling — all within a single Async reactor.<br><br>
|
|
12
|
+
<strong>Key Features</strong><br>
|
|
13
|
+
|
|
14
|
+
- <strong>Typed Channels</strong> - Restrict messages to a specific class<br>
|
|
15
|
+
- <strong>ACK-Based Delivery</strong> - Subscribers must explicitly ack or nack<br>
|
|
16
|
+
- <strong>Dead Letter Queues</strong> - Collect nacked and timed-out deliveries<br>
|
|
17
|
+
- <strong>Backpressure</strong> - Bound pending deliveries per channel<br>
|
|
18
|
+
- <strong>Adaptive Throttling</strong> - Progressive slowdown as capacity fills<br>
|
|
19
|
+
- <strong>Configuration Cascade</strong> - Global → Bus → Channel defaults<br>
|
|
20
|
+
- <strong>Stats Tracking</strong> - Per-channel publish/deliver/nack/timeout counters<br>
|
|
21
|
+
- <strong>Structured Logging</strong> - Optional Logger integration across all components
|
|
22
|
+
</td>
|
|
23
|
+
</tr>
|
|
24
|
+
</table>
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Add to your Gemfile:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "typed_bus"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then run:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "typed_bus"
|
|
44
|
+
|
|
45
|
+
bus = TypedBus::MessageBus.new
|
|
46
|
+
bus.add_channel(:events, timeout: 5)
|
|
47
|
+
|
|
48
|
+
bus.subscribe(:events) do |delivery|
|
|
49
|
+
puts delivery.message
|
|
50
|
+
delivery.ack!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Async do
|
|
54
|
+
bus.publish(:events, "hello world")
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Channels
|
|
59
|
+
|
|
60
|
+
A channel is a named pub/sub topic. Subscribers receive `Delivery` envelopes and must call `ack!` or `nack!`.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
channel = TypedBus::Channel.new(:orders, timeout: 10)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Options
|
|
67
|
+
|
|
68
|
+
| Option | Type | Default | Description |
|
|
69
|
+
|--------|------|---------|-------------|
|
|
70
|
+
| `type` | `Class` | `nil` | Restricts published messages to a specific class |
|
|
71
|
+
| `timeout` | `Numeric` | `30` | Seconds before unresolved deliveries auto-nack |
|
|
72
|
+
| `max_pending` | `Integer` | `nil` | Backpressure limit; `nil` = unbounded |
|
|
73
|
+
| `throttle` | `Float` | `0.0` | Remaining-capacity ratio where backoff begins; `0.0` = disabled |
|
|
74
|
+
|
|
75
|
+
### Typed Channels
|
|
76
|
+
|
|
77
|
+
Restrict a channel to a single message class:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
channel = TypedBus::Channel.new(:orders, type: Order)
|
|
81
|
+
|
|
82
|
+
Async do
|
|
83
|
+
channel.publish(Order.new) # OK
|
|
84
|
+
channel.publish("not valid") # raises ArgumentError
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Subscribe and Unsubscribe
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
id = channel.subscribe do |delivery|
|
|
92
|
+
process(delivery.message)
|
|
93
|
+
delivery.ack!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
channel.unsubscribe(id)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Publish
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Async do
|
|
103
|
+
tracker = channel.publish(my_message)
|
|
104
|
+
# tracker is a DeliveryTracker (nil if no subscribers)
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Delivery
|
|
109
|
+
|
|
110
|
+
Each subscriber receives a `Delivery` envelope wrapping the published message. The subscriber must resolve it:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
channel.subscribe do |delivery|
|
|
114
|
+
delivery.message # the published object
|
|
115
|
+
delivery.channel_name
|
|
116
|
+
delivery.subscriber_id
|
|
117
|
+
|
|
118
|
+
delivery.ack! # mark as successfully processed
|
|
119
|
+
# or
|
|
120
|
+
delivery.nack! # mark as failed (routes to dead letter queue)
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If neither `ack!` nor `nack!` is called before the channel's `timeout`, the delivery auto-nacks.
|
|
125
|
+
|
|
126
|
+
### Delivery States
|
|
127
|
+
|
|
128
|
+
| Method | Description |
|
|
129
|
+
|--------|-------------|
|
|
130
|
+
| `pending?` | Not yet resolved |
|
|
131
|
+
| `acked?` | Successfully acknowledged |
|
|
132
|
+
| `nacked?` | Explicitly rejected or timed out |
|
|
133
|
+
| `timed_out?` | True if the nack was caused by timeout |
|
|
134
|
+
|
|
135
|
+
## Dead Letter Queue
|
|
136
|
+
|
|
137
|
+
Every channel has a dead letter queue that collects failed deliveries (nacked or timed out).
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
channel.dead_letter_queue.size
|
|
141
|
+
channel.dead_letter_queue.empty?
|
|
142
|
+
|
|
143
|
+
# Iterate without removing
|
|
144
|
+
channel.dead_letter_queue.each do |delivery|
|
|
145
|
+
puts "#{delivery.message} failed on subscriber ##{delivery.subscriber_id}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Drain and reprocess
|
|
149
|
+
channel.dead_letter_queue.drain do |delivery|
|
|
150
|
+
retry_message(delivery.message)
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Messages published with no subscribers are also routed to the DLQ.
|
|
155
|
+
|
|
156
|
+
### DLQ Callback
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
channel.dead_letter_queue.on_dead_letter do |delivery|
|
|
160
|
+
alert("Dead letter: #{delivery.message}")
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Backpressure
|
|
165
|
+
|
|
166
|
+
Set `max_pending` to bound how many unresolved deliveries a channel allows. When the limit is reached, `publish` blocks the calling fiber until subscribers ack.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
channel = TypedBus::Channel.new(:work,
|
|
170
|
+
max_pending: 100,
|
|
171
|
+
timeout: 10
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Adaptive Throttling
|
|
176
|
+
|
|
177
|
+
When a bounded channel approaches capacity, adaptive throttling progressively slows publishers before they hit the hard block.
|
|
178
|
+
|
|
179
|
+
Set `throttle` to a `Float` between `0.0` (exclusive) and `1.0` (exclusive). The value is the remaining-capacity ratio at which backoff begins. Below that threshold, each publish sleeps for `1 / (max_pending * remaining_ratio)` seconds.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
channel = TypedBus::Channel.new(:pipeline,
|
|
183
|
+
max_pending: 10,
|
|
184
|
+
throttle: 0.5 # begin backoff at 50% remaining capacity
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Backoff Curve
|
|
189
|
+
|
|
190
|
+
With `max_pending: 10, throttle: 0.5`:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
Publish Pending Remaining Ratio Delay
|
|
194
|
+
───────────────────────────────────────────
|
|
195
|
+
1 0 10 1.0 —
|
|
196
|
+
2 1 9 0.9 —
|
|
197
|
+
3 2 8 0.8 —
|
|
198
|
+
4 3 7 0.7 —
|
|
199
|
+
5 4 6 0.6 —
|
|
200
|
+
6 5 5 0.5 0.200s
|
|
201
|
+
7 6 4 0.4 0.250s
|
|
202
|
+
8 7 3 0.3 0.333s
|
|
203
|
+
9 8 2 0.2 0.500s
|
|
204
|
+
10 9 1 0.1 1.000s
|
|
205
|
+
11 10 0 0.0 hard block
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The first 50% of capacity fills at full speed. The last 50% applies an asymptotic delay that approaches infinity as remaining capacity approaches zero. At zero remaining, the existing `wait_for_capacity` hard-blocks until a subscriber acks.
|
|
209
|
+
|
|
210
|
+
Higher `throttle` values begin backoff earlier:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
TypedBus::Channel.new(:conservative, max_pending: 100, throttle: 0.8) # backoff at 80% remaining
|
|
214
|
+
TypedBus::Channel.new(:aggressive, max_pending: 100, throttle: 0.3) # backoff at 30% remaining
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Configuration
|
|
218
|
+
|
|
219
|
+
TypedBus resolves parameters through a three-tier cascade: **Global → Bus → Channel**. Each tier inherits from the one above unless explicitly overridden.
|
|
220
|
+
|
|
221
|
+
| Parameter | Default | Description |
|
|
222
|
+
|-----------|---------|-------------|
|
|
223
|
+
| `timeout` | `30` | Delivery ACK deadline in seconds |
|
|
224
|
+
| `max_pending` | `nil` | Backpressure limit; `nil` = unbounded |
|
|
225
|
+
| `throttle` | `0.0` | Remaining-capacity ratio where backoff begins; `0.0` = disabled |
|
|
226
|
+
| `logger` | `nil` | Ruby Logger instance (global-only) |
|
|
227
|
+
| `log_level` | `Logger::INFO` | Log level applied to the logger (global-only) |
|
|
228
|
+
|
|
229
|
+
### Global Defaults
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
TypedBus.configure do |config|
|
|
233
|
+
config.timeout = 60
|
|
234
|
+
config.max_pending = 500
|
|
235
|
+
config.throttle = 0.5
|
|
236
|
+
config.logger = Logger.new($stdout)
|
|
237
|
+
config.log_level = Logger::DEBUG
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Cascade Example
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Global: timeout=60, max_pending=500
|
|
245
|
+
TypedBus.configure do |config|
|
|
246
|
+
config.timeout = 60
|
|
247
|
+
config.max_pending = 500
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Bus overrides timeout, inherits max_pending
|
|
251
|
+
bus = TypedBus::MessageBus.new(timeout: 30)
|
|
252
|
+
|
|
253
|
+
# Channel inherits both from bus (timeout=30, max_pending=500)
|
|
254
|
+
bus.add_channel(:orders)
|
|
255
|
+
|
|
256
|
+
# Channel overrides timeout, inherits max_pending from bus
|
|
257
|
+
bus.add_channel(:alerts, timeout: 5)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Reset
|
|
261
|
+
|
|
262
|
+
Restore factory defaults (useful in tests):
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
TypedBus.reset_configuration!
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## MessageBus
|
|
269
|
+
|
|
270
|
+
`MessageBus` is a registry facade that manages multiple named channels with shared stats.
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
bus = TypedBus::MessageBus.new
|
|
274
|
+
bus.add_channel(:orders, type: Order, max_pending: 100, timeout: 10)
|
|
275
|
+
bus.add_channel(:alerts, type: String)
|
|
276
|
+
|
|
277
|
+
bus.subscribe(:orders) { |d| process(d.message); d.ack! }
|
|
278
|
+
bus.subscribe(:alerts) { |d| log(d.message); d.ack! }
|
|
279
|
+
|
|
280
|
+
Async do
|
|
281
|
+
bus.publish(:orders, Order.new)
|
|
282
|
+
bus.publish(:alerts, "system ok")
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Bus-Level Defaults
|
|
287
|
+
|
|
288
|
+
Pass `timeout`, `max_pending`, or `throttle` to the constructor to set defaults for all channels on this bus:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
bus = TypedBus::MessageBus.new(timeout: 10, max_pending: 200, throttle: 0.5)
|
|
292
|
+
|
|
293
|
+
# Inherits all bus defaults
|
|
294
|
+
bus.add_channel(:orders, type: Order)
|
|
295
|
+
|
|
296
|
+
# Overrides throttle, inherits timeout and max_pending
|
|
297
|
+
bus.add_channel(:alerts, throttle: 0.3)
|
|
298
|
+
|
|
299
|
+
# Disables throttle for this channel
|
|
300
|
+
bus.add_channel(:logs, throttle: 0.0)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Bus Methods
|
|
304
|
+
|
|
305
|
+
| Method | Description |
|
|
306
|
+
|--------|-------------|
|
|
307
|
+
| `add_channel(name, **opts)` | Register a named channel |
|
|
308
|
+
| `remove_channel(name)` | Close and remove a channel |
|
|
309
|
+
| `publish(name, message)` | Publish to a named channel |
|
|
310
|
+
| `subscribe(name, &block)` | Subscribe to a named channel |
|
|
311
|
+
| `unsubscribe(name, id)` | Remove a subscriber |
|
|
312
|
+
| `pending?(name)` | Check for unresolved deliveries |
|
|
313
|
+
| `pending_count(name)` | Count unresolved deliveries |
|
|
314
|
+
| `dead_letters(name)` | Access a channel's DLQ |
|
|
315
|
+
| `close(name)` | Close a specific channel |
|
|
316
|
+
| `close_all` | Close all channels |
|
|
317
|
+
| `channel?(name)` | Check if a channel exists |
|
|
318
|
+
| `channel_names` | List registered channel names |
|
|
319
|
+
| `clear!` | Reset all channels and stats |
|
|
320
|
+
| `stats` | Access the shared Stats object |
|
|
321
|
+
|
|
322
|
+
## Stats
|
|
323
|
+
|
|
324
|
+
`MessageBus` tracks per-channel counters automatically:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
bus.stats[:orders_published] # messages published
|
|
328
|
+
bus.stats[:orders_delivered] # all subscribers acked
|
|
329
|
+
bus.stats[:orders_nacked] # explicit nacks
|
|
330
|
+
bus.stats[:orders_timed_out] # delivery timeouts
|
|
331
|
+
bus.stats[:orders_dead_lettered] # entries added to DLQ
|
|
332
|
+
bus.stats[:orders_throttled] # publishes that were rate-limited
|
|
333
|
+
|
|
334
|
+
bus.stats.to_h # snapshot of all counters
|
|
335
|
+
bus.stats.reset! # zero all counters
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
When using `Channel` directly, pass a `Stats` instance:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
stats = TypedBus::Stats.new
|
|
342
|
+
channel = TypedBus::Channel.new(:work, stats: stats, timeout: 5)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Logging
|
|
346
|
+
|
|
347
|
+
Assign a standard Ruby `Logger` to get structured log output from all components:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
TypedBus.configure do |config|
|
|
351
|
+
config.logger = Logger.new($stdout)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# or use the shortcut
|
|
355
|
+
TypedBus.logger = Logger.new($stdout)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Set to `nil` (the default) to disable logging.
|
|
359
|
+
|
|
360
|
+
Log output covers channel lifecycle, publish/subscribe events, delivery resolution, DLQ entries, backpressure waits, and throttle delays.
|
|
361
|
+
|
|
362
|
+
## Closing and Cleanup
|
|
363
|
+
|
|
364
|
+
### Close a Channel
|
|
365
|
+
|
|
366
|
+
Stops accepting new publishes and subscribes. Pending deliveries are force-nacked and routed to the DLQ.
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
channel.close
|
|
370
|
+
channel.closed? # => true
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Clear a Channel
|
|
374
|
+
|
|
375
|
+
Hard reset: cancels all timeout tasks, discards pending state and DLQ contents.
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
channel.clear!
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Bus Shutdown
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
bus.close_all # close every channel
|
|
385
|
+
bus.clear! # close, clear DLQs, and reset stats
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Architecture
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
Publisher
|
|
392
|
+
│
|
|
393
|
+
▼
|
|
394
|
+
Channel (type check → throttle → backpressure → fan-out)
|
|
395
|
+
│
|
|
396
|
+
├──▶ Subscriber 1 ──▶ Delivery ──▶ ack!/nack!
|
|
397
|
+
├──▶ Subscriber 2 ──▶ Delivery ──▶ ack!/nack!
|
|
398
|
+
└──▶ Subscriber N ──▶ Delivery ──▶ ack!/nack!
|
|
399
|
+
│
|
|
400
|
+
▼
|
|
401
|
+
DeliveryTracker (aggregates N responses)
|
|
402
|
+
│
|
|
403
|
+
├── all acked ──▶ :delivered stat
|
|
404
|
+
└── any nacked ──▶ Dead Letter Queue
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Concurrency model:** Fiber-only, powered by the `async` gem. No mutexes anywhere. All state is managed within a single Async reactor. `sleep` calls inside throttle and backpressure yield the fiber, not the thread.
|
|
408
|
+
|
|
409
|
+
## Requirements
|
|
410
|
+
|
|
411
|
+
- Ruby >= 3.2.0
|
|
412
|
+
- [async](https://github.com/socketry/async) ~> 2.0
|
|
413
|
+
|
|
414
|
+
## License
|
|
415
|
+
|
|
416
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.test_prelude = 'require "test_helper"'
|
|
9
|
+
t.test_globs = ["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task default: :test
|
data/docs/api/channel.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Channel
|
|
2
|
+
|
|
3
|
+
`TypedBus::Channel`
|
|
4
|
+
|
|
5
|
+
A named, typed pub/sub channel with explicit acknowledgment tracking.
|
|
6
|
+
|
|
7
|
+
## Constructor
|
|
8
|
+
|
|
9
|
+
### `Channel.new(name, type:, timeout:, max_pending:, stats:, throttle:)`
|
|
10
|
+
|
|
11
|
+
| Parameter | Default | Description |
|
|
12
|
+
|-----------|---------|-------------|
|
|
13
|
+
| `name` | required | `Symbol` channel name |
|
|
14
|
+
| `type` | `nil` | Optional class constraint for messages |
|
|
15
|
+
| `timeout` | `30` | Seconds before delivery auto-nacks |
|
|
16
|
+
| `max_pending` | `nil` | Backpressure limit; `nil` = unbounded |
|
|
17
|
+
| `stats` | `nil` | Optional `Stats` instance for counters |
|
|
18
|
+
| `throttle` | `0.0` | Capacity ratio where backoff begins |
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
channel = TypedBus::Channel.new(:orders,
|
|
22
|
+
type: Order,
|
|
23
|
+
timeout: 10,
|
|
24
|
+
max_pending: 100,
|
|
25
|
+
throttle: 0.5
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
!!! note
|
|
30
|
+
When using channels via `MessageBus`, these values are resolved through the configuration cascade. You don't normally construct channels directly with all parameters.
|
|
31
|
+
|
|
32
|
+
## Attributes
|
|
33
|
+
|
|
34
|
+
### `name` → `Symbol`
|
|
35
|
+
|
|
36
|
+
Channel name.
|
|
37
|
+
|
|
38
|
+
### `type` → `Class`, `nil`
|
|
39
|
+
|
|
40
|
+
Type constraint, or `nil` for untyped channels.
|
|
41
|
+
|
|
42
|
+
### `dead_letter_queue` → `DeadLetterQueue`
|
|
43
|
+
|
|
44
|
+
The channel's dead letter queue.
|
|
45
|
+
|
|
46
|
+
## Pub/Sub
|
|
47
|
+
|
|
48
|
+
### `publish(message)` → `DeliveryTracker`, `nil`
|
|
49
|
+
|
|
50
|
+
Publish a message to all current subscribers.
|
|
51
|
+
|
|
52
|
+
- Blocks the calling fiber if bounded and at capacity
|
|
53
|
+
- Applies throttle delay if configured
|
|
54
|
+
- Returns `nil` if no subscribers (message goes to DLQ)
|
|
55
|
+
|
|
56
|
+
**Raises:** `ArgumentError` for type mismatches. `RuntimeError` if closed.
|
|
57
|
+
|
|
58
|
+
### `subscribe(&block)` → `Integer`
|
|
59
|
+
|
|
60
|
+
Subscribe to messages. Returns the subscriber ID.
|
|
61
|
+
|
|
62
|
+
**Raises:** `RuntimeError` if closed.
|
|
63
|
+
|
|
64
|
+
### `unsubscribe(id_or_block)`
|
|
65
|
+
|
|
66
|
+
Remove a subscriber by integer ID or block reference.
|
|
67
|
+
|
|
68
|
+
## Query
|
|
69
|
+
|
|
70
|
+
### `subscriber_count` → `Integer`
|
|
71
|
+
|
|
72
|
+
Number of active subscribers.
|
|
73
|
+
|
|
74
|
+
### `pending_count` → `Integer`
|
|
75
|
+
|
|
76
|
+
Number of unresolved delivery trackers.
|
|
77
|
+
|
|
78
|
+
### `pending?` → `Boolean`
|
|
79
|
+
|
|
80
|
+
True when there are unresolved deliveries.
|
|
81
|
+
|
|
82
|
+
### `closed?` → `Boolean`
|
|
83
|
+
|
|
84
|
+
True if the channel has been closed.
|
|
85
|
+
|
|
86
|
+
## Lifecycle
|
|
87
|
+
|
|
88
|
+
### `close`
|
|
89
|
+
|
|
90
|
+
Stop accepting new publishes and subscribes. Pending deliveries are force-NACKed and routed to the DLQ.
|
|
91
|
+
|
|
92
|
+
### `clear!`
|
|
93
|
+
|
|
94
|
+
Hard reset: cancel all timeout tasks, discard pending state, clear DLQ. Does not mark the channel as closed.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
`TypedBus::Configuration`
|
|
4
|
+
|
|
5
|
+
Holds configurable defaults for the message bus system. Supports three-tier cascade: Global → Bus → Channel.
|
|
6
|
+
|
|
7
|
+
## Attributes
|
|
8
|
+
|
|
9
|
+
| Attribute | Type | Default | Description |
|
|
10
|
+
|-----------|------|---------|-------------|
|
|
11
|
+
| `timeout` | `Numeric` | `30` | Delivery ACK deadline in seconds |
|
|
12
|
+
| `max_pending` | `Integer`, `nil` | `nil` | Backpressure limit; `nil` = unbounded |
|
|
13
|
+
| `throttle` | `Float` | `0.0` | Remaining-capacity ratio where backoff begins |
|
|
14
|
+
| `logger` | `Logger`, `nil` | `nil` | Ruby Logger instance |
|
|
15
|
+
| `log_level` | `Integer` | `Logger::INFO` | Log level applied to the logger |
|
|
16
|
+
|
|
17
|
+
## Constructor
|
|
18
|
+
|
|
19
|
+
### `Configuration.new`
|
|
20
|
+
|
|
21
|
+
Creates a new Configuration with factory defaults.
|
|
22
|
+
|
|
23
|
+
## Methods
|
|
24
|
+
|
|
25
|
+
### `timeout=`, `max_pending=`, `throttle=`
|
|
26
|
+
|
|
27
|
+
Standard setters for cascade parameters.
|
|
28
|
+
|
|
29
|
+
### `logger=(log)`
|
|
30
|
+
|
|
31
|
+
Sets the logger and applies the current `log_level` to it.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
config.logger = Logger.new($stdout)
|
|
35
|
+
# config.logger.level is now config.log_level
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `log_level=(level)`
|
|
39
|
+
|
|
40
|
+
Sets the log level and applies it to the current logger (if present).
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
config.log_level = Logger::DEBUG
|
|
44
|
+
# config.logger.level is now Logger::DEBUG (if logger is set)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### `resolve(timeout:, max_pending:, throttle:)`
|
|
48
|
+
|
|
49
|
+
Merges overrides on top of this configuration's values. Parameters set to `:use_default` inherit from this config.
|
|
50
|
+
|
|
51
|
+
**Parameters:**
|
|
52
|
+
|
|
53
|
+
| Parameter | Default | Description |
|
|
54
|
+
|-----------|---------|-------------|
|
|
55
|
+
| `timeout` | `:use_default` | Delivery ACK deadline |
|
|
56
|
+
| `max_pending` | `:use_default` | Backpressure limit |
|
|
57
|
+
| `throttle` | `:use_default` | Capacity ratio for adaptive backoff |
|
|
58
|
+
|
|
59
|
+
**Returns:** `Hash` with resolved `:timeout`, `:max_pending`, and `:throttle` values.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
config = TypedBus::Configuration.new
|
|
63
|
+
config.timeout = 60
|
|
64
|
+
|
|
65
|
+
config.resolve(timeout: 10)
|
|
66
|
+
# => { timeout: 10, max_pending: nil, throttle: 0.0 }
|
|
67
|
+
|
|
68
|
+
config.resolve
|
|
69
|
+
# => { timeout: 60, max_pending: nil, throttle: 0.0 }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `dup`
|
|
73
|
+
|
|
74
|
+
Returns an independent shallow copy. The logger is intentionally shared (same Logger instance); all other fields are independent.
|
|
75
|
+
|
|
76
|
+
## Constants
|
|
77
|
+
|
|
78
|
+
### `DEFAULTS`
|
|
79
|
+
|
|
80
|
+
Frozen hash of factory defaults:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
{
|
|
84
|
+
timeout: 30,
|
|
85
|
+
max_pending: nil,
|
|
86
|
+
throttle: 0.0,
|
|
87
|
+
logger: nil,
|
|
88
|
+
log_level: Logger::INFO
|
|
89
|
+
}
|
|
90
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# DeadLetterQueue
|
|
2
|
+
|
|
3
|
+
`TypedBus::DeadLetterQueue`
|
|
4
|
+
|
|
5
|
+
Stores deliveries that failed — either explicitly NACKed or timed out. Each Channel owns a DLQ. Includes `Enumerable`.
|
|
6
|
+
|
|
7
|
+
## Constructor
|
|
8
|
+
|
|
9
|
+
### `DeadLetterQueue.new(&on_dead_letter)`
|
|
10
|
+
|
|
11
|
+
Optionally accepts a block that fires on each `push`.
|
|
12
|
+
|
|
13
|
+
## Methods
|
|
14
|
+
|
|
15
|
+
### `push(delivery)`
|
|
16
|
+
|
|
17
|
+
Add a failed delivery.
|
|
18
|
+
|
|
19
|
+
### `size` → `Integer`
|
|
20
|
+
|
|
21
|
+
Number of entries.
|
|
22
|
+
|
|
23
|
+
### `empty?` → `Boolean`
|
|
24
|
+
|
|
25
|
+
True if no entries.
|
|
26
|
+
|
|
27
|
+
### `each(&block)`
|
|
28
|
+
|
|
29
|
+
Iterate over dead letters without removing them.
|
|
30
|
+
|
|
31
|
+
### `drain { |delivery| ... }` → `Array<Delivery>`
|
|
32
|
+
|
|
33
|
+
Yield each dead letter and remove it from the queue. Returns the drained entries. Without a block, just removes and returns them.
|
|
34
|
+
|
|
35
|
+
### `clear!`
|
|
36
|
+
|
|
37
|
+
Discard all entries.
|
|
38
|
+
|
|
39
|
+
### `on_dead_letter { |delivery| ... }`
|
|
40
|
+
|
|
41
|
+
Register or replace the callback fired when entries arrive.
|
|
42
|
+
|
|
43
|
+
## Enumerable
|
|
44
|
+
|
|
45
|
+
`DeadLetterQueue` includes `Enumerable`, so you can use `map`, `select`, `count`, `any?`, etc.:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
dlq = channel.dead_letter_queue
|
|
49
|
+
|
|
50
|
+
timed_out = dlq.count(&:timed_out?)
|
|
51
|
+
messages = dlq.map(&:message)
|
|
52
|
+
```
|