bug_bunny 4.6.1 → 4.8.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/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
- data/.claude/commands/gem-ai-setup.md +174 -0
- data/.claude/commands/pr.md +53 -0
- data/.claude/commands/release.md +52 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/service-ai-setup.md +168 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +50 -15
- data/CLAUDE.md +240 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/_index.md +50 -0
- data/docs/ai/_index.md +56 -0
- data/docs/ai/antipatterns.md +166 -0
- data/docs/ai/api.md +251 -0
- data/docs/ai/architecture.md +92 -0
- data/docs/ai/errors.md +158 -0
- data/docs/ai/faq_external.md +133 -0
- data/docs/ai/faq_internal.md +86 -0
- data/docs/ai/glossary.md +45 -0
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +45 -21
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +39 -18
- data/lib/bug_bunny/exception.rb +5 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +28 -6
- data/lib/bug_bunny/producer.rb +11 -13
- data/lib/bug_bunny/railtie.rb +8 -7
- data/lib/bug_bunny/request.rb +3 -11
- data/lib/bug_bunny/resource.rb +81 -41
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +60 -22
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +4 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/plan_test.txt +63 -0
- data/skills-lock.json +10 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +50 -3
- data/sig/bug_bunny.rbs +0 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
## FAQ — External (Integrator / Consumer of BugBunny)
|
|
2
|
+
|
|
3
|
+
### How do I set up BugBunny in a Rails app?
|
|
4
|
+
|
|
5
|
+
Add to Gemfile: `gem 'bug_bunny'` and `gem 'connection_pool'`. Run `rails generate bug_bunny:install`. Edit `config/initializers/bug_bunny.rb` with your RabbitMQ credentials. Create a pool: `MY_POOL = ConnectionPool.new(size: 5) { BugBunny.create_connection }`. Assign it: `BugBunny::Resource.connection_pool = MY_POOL`. See `docs/howto/rails.md` for the complete setup.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### How do I make an RPC call (blocking request)?
|
|
10
|
+
|
|
11
|
+
Use `Resource.find` / `Resource.where` / `Resource.create` — they all do RPC internally. For lower-level control: `client.request('users/1', method: :get, exchange: 'users_exchange', routing_key: 'users')`. The call blocks until the remote service replies or `rpc_timeout` expires.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### How do I publish without waiting for a reply?
|
|
16
|
+
|
|
17
|
+
Use `client.publish('events', method: :post, exchange: 'events_exchange', routing_key: 'events', body: { ... })`. Fire-and-forget — does not block. No RPC timeout applies.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### What happens when `find` returns nil vs raises?
|
|
22
|
+
|
|
23
|
+
`Resource.find` returns `nil` on 404 (does not raise). `Resource.where` returns `[]` on 404. Both raise `BugBunny::RequestTimeout` if the remote service does not reply within `rpc_timeout`. They raise `BugBunny::ServerError` on 5xx responses.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### How do I handle validation errors from the remote service?
|
|
28
|
+
|
|
29
|
+
`resource.save` returns `false` on 422 and loads the remote errors into `resource.errors`. Check `resource.valid?` then `resource.errors.full_messages`. The remote service must render `{ errors: { field: ['message'] } }` for the errors to be auto-loaded.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### How do I use `.with` to override the exchange per call?
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Block form (preferred — thread-safe, restores after block)
|
|
37
|
+
Order.with(exchange: 'priority_exchange', routing_key: 'priority') do
|
|
38
|
+
Order.find(id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Single-call proxy
|
|
42
|
+
Order.with(exchange: 'priority_exchange').find(id)
|
|
43
|
+
# ScopeProxy is single-use — calling a second method raises BugBunny::Error
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### How do I define a Resource with typed attributes?
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class User < BugBunny::Resource
|
|
52
|
+
self.exchange = 'users_exchange'
|
|
53
|
+
|
|
54
|
+
attribute :id, :integer
|
|
55
|
+
attribute :name, :string
|
|
56
|
+
attribute :email, :string
|
|
57
|
+
|
|
58
|
+
validates :name, presence: true
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Typed attributes get ActiveModel coercion and dirty tracking. Attributes not declared with `attribute` are handled dynamically via `method_missing` and tracked in `@extra_attributes`.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### How do I add client-side middleware to a Resource?
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class Order < BugBunny::Resource
|
|
70
|
+
client_middleware do |stack|
|
|
71
|
+
stack.use MyRetryMiddleware
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Middlewares are inherited by subclasses and applied in registration order (first = outermost). `RaiseError` and `JsonResponse` are always added as the innermost middlewares by `Resource` — do not add them manually.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### How do I run the Consumer in a Rails app?
|
|
81
|
+
|
|
82
|
+
The Consumer is a blocking loop. Run it in a separate process, not inside Puma:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# lib/tasks/rabbit.rake
|
|
86
|
+
task consumer: :environment do
|
|
87
|
+
conn = BugBunny.create_connection
|
|
88
|
+
consumer = BugBunny::Consumer.new(conn)
|
|
89
|
+
trap('TERM') { consumer.shutdown; exit }
|
|
90
|
+
trap('INT') { consumer.shutdown; exit }
|
|
91
|
+
consumer.subscribe(
|
|
92
|
+
queue_name: 'my_queue', exchange_name: 'my_exchange', routing_key: 'my_key'
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### How do I write a Controller?
|
|
100
|
+
|
|
101
|
+
Subclass `BugBunny::Controller`. Place it in the namespace from `config.controller_namespace` (default: `BugBunny::Controllers`). Implement action methods (`index`, `show`, `create`, `update`, `destroy` or custom). Call `render(status:, json:)` to respond. The Consumer routes messages to `YourController.call(headers:, body:)`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### How do I propagate trace context through RabbitMQ?
|
|
106
|
+
|
|
107
|
+
On the consumer side, inject headers into replies:
|
|
108
|
+
```ruby
|
|
109
|
+
config.rpc_reply_headers = -> { { 'X-Trace-Id' => Tracer.current } }
|
|
110
|
+
```
|
|
111
|
+
On the producer side, hydrate context from the reply:
|
|
112
|
+
```ruby
|
|
113
|
+
config.on_rpc_reply = ->(headers) { Tracer.hydrate(headers['X-Trace-Id']) }
|
|
114
|
+
```
|
|
115
|
+
For consumer-side middleware (propagating from incoming request), use `ConsumerMiddleware`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### How do I configure health checks for Kubernetes?
|
|
120
|
+
|
|
121
|
+
Set `config.health_check_file = '/app/tmp/bug_bunny_health'`. BugBunny touches that file every `health_check_interval` seconds (default: 60) after verifying the RabbitMQ connection. Add a `livenessProbe` in your Kubernetes manifest checking for that file's existence.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### What is the default RPC timeout and how do I change it?
|
|
126
|
+
|
|
127
|
+
Default: 10 seconds. Override globally: `config.rpc_timeout = 30`. Override per request: `client.request('users/1', method: :get, exchange: ..., timeout: 5)`. When exceeded, `BugBunny::RequestTimeout` is raised.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### Do I need to declare exchanges and queues manually?
|
|
132
|
+
|
|
133
|
+
No. BugBunny declares them automatically when `subscribe` (consumer) or the first RPC call (producer) is made. Use `config.exchange_options` and `config.queue_options` for global defaults (e.g., `{ durable: true }`). Override per resource with `Resource.exchange_options=` or per call with `.with(exchange_options:)`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
## FAQ — Internal (Maintainer / Developer of BugBunny)
|
|
2
|
+
|
|
3
|
+
### Why is Producer cached per connection slot instead of instantiated per request?
|
|
4
|
+
|
|
5
|
+
`Producer#initialize` calls `channel.basic_consume` on the `amq.rabbitmq.reply-to` pseudo-queue to listen for RPC replies. Creating a second `basic_consume` on the same channel raises an AMQP error. Therefore, one Producer per channel (= per connection slot) is mandatory. The cache is stored as `@_bug_bunny_producer` ivar on the Bunny connection object, which is safe because `ConnectionPool` guarantees each slot is used by one thread at a time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### Why is Session cached per connection slot?
|
|
10
|
+
|
|
11
|
+
Same reason as Producer — and the Session wraps the channel. Recreating a Session would create a new channel and invalidate the cached exchange/queue objects. Caching is stored as `@_bug_bunny_session` on the Bunny connection object.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### How does the Session handle thread safety for exchange/queue declarations?
|
|
16
|
+
|
|
17
|
+
Session uses a double-checked locking pattern with a `Mutex` for its caches (`@exchange_cache`, `@queue_cache`). The read path (fast path) checks without locking; the write path acquires the mutex and checks again before writing. This avoids redundant AMQP declarations without making every read a mutex acquisition.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
### Why does Resource have two dirty tracking mechanisms?
|
|
22
|
+
|
|
23
|
+
ActiveModel::Dirty only tracks attributes declared with `attribute :name, :type`. BugBunny Resources allow dynamic attributes (unknown at class definition time) stored in `@extra_attributes`. These are tracked manually via `@dynamic_changes` (a `Set`). The `changed?` and `changed` methods merge both to produce a unified change list.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### How does the 3-level config cascade work in Resource?
|
|
28
|
+
|
|
29
|
+
`resolve_config(key, instance_var)` checks in order:
|
|
30
|
+
1. `Thread.current["bb_#{object_id}_#{key}"]` — set by `.with(...)`.
|
|
31
|
+
2. `self.instance_variable_get(instance_var)` — class-level static config.
|
|
32
|
+
3. Walk `superclass` chain up to (but not including) `BugBunny::Resource`.
|
|
33
|
+
|
|
34
|
+
This means a subclass can override the parent's config, and `.with` overrides everything for the duration of its block.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### How does ConsumerMiddleware::Stack protect against concurrent registration?
|
|
39
|
+
|
|
40
|
+
`Stack#use` wraps the append in a `@mutex.synchronize` block. The execution path (`call`) does not use the mutex — it reads `@middlewares` once at call time (snapshot). This is safe because Rails/Zeitwerk loads middleware registrations at boot, before any concurrent requests.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### How does routing with namespaces work internally?
|
|
45
|
+
|
|
46
|
+
`namespace :admin { resources :users }` generates `Route` objects with:
|
|
47
|
+
- `path_prefix: 'admin'` — prepended to the path pattern.
|
|
48
|
+
- `namespace: 'Admin::Controllers'` — overrides the default namespace for `constantize`.
|
|
49
|
+
|
|
50
|
+
In `process_message`, the Consumer resolves `base_namespace = route_info[:namespace] || config.controller_namespace` before calling `constantize`.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### What is the RCE prevention in the Consumer?
|
|
55
|
+
|
|
56
|
+
After `constantize`, the Consumer checks `controller_class < BugBunny::Controller`. This prevents an attacker from crafting a message with a `type` header pointing to an arbitrary Ruby class (e.g., `Kernel` or `File`) and triggering its methods. Any class not inheriting from `BugBunny::Controller` gets a 403 reply and the message is rejected.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### How does `before_action` halt chain propagation?
|
|
61
|
+
|
|
62
|
+
`run_before_actions` iterates before-actions and calls each. After each call, it checks `rendered_response`. If a filter called `render(...)`, `@rendered_response` is set. The method returns `false`, and `core_execution` skips the action and `after_actions`. After-actions do not run if the before-action chain was halted.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### How does `after_action` differ from `around_action`?
|
|
67
|
+
|
|
68
|
+
`after_action` runs after the action method returns, but only if no `before_action` halted the chain and no exception was raised. `around_action` wraps the entire execution including before/after actions and is responsible for yielding. Use `around_action` when you need cleanup regardless of exceptions.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### How does `safe_log` prevent log failures from affecting main flow?
|
|
73
|
+
|
|
74
|
+
`safe_log` wraps every logger call in `rescue StandardError`. It also filters sensitive keys by checking if the key string matches a predefined set of patterns (`password`, `pass`, `passwd`, `secret`, `token`, `api_key`, `auth`) before emitting values. Blocks are always passed to `logger.debug` to avoid string interpolation cost at non-debug levels.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### How is the Consumer health check implemented?
|
|
79
|
+
|
|
80
|
+
`start_health_check` creates a `Concurrent::TimerTask` that runs every `health_check_interval` seconds. Each tick calls `channel.queue_declare(queue_name, passive: true)` — a passive declare that verifies the queue exists without creating it. On failure, it calls `session.close`, which triggers the reconnect loop in `subscribe`. The health file is touched on success.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### What triggers the reconnect loop in Consumer?
|
|
85
|
+
|
|
86
|
+
Any `StandardError` raised inside the `subscribe` rescue block. The loop uses exponential backoff: `wait = [interval * 2^(attempt-1), max_interval].min`. If `max_reconnect_attempts` is set and exceeded, the error is re-raised, killing the process.
|
data/docs/ai/glossary.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## Glossary
|
|
2
|
+
|
|
3
|
+
**AMQP** — Advanced Message Queuing Protocol. The binary wire protocol used by RabbitMQ. BugBunny uses Bunny as its AMQP client.
|
|
4
|
+
|
|
5
|
+
**Exchange** — A RabbitMQ routing point. Publishers send messages to exchanges; exchanges route them to queues based on bindings and routing keys. BugBunny supports `direct`, `topic`, and `fanout` types.
|
|
6
|
+
|
|
7
|
+
**Queue** — A buffer that holds messages until they are consumed. A queue is bound to an exchange with a routing key pattern.
|
|
8
|
+
|
|
9
|
+
**Routing key** — A string label attached to a published message. The exchange uses it to decide which queues receive the message. In BugBunny, the routing key defaults to the pluralized resource name (e.g., `users`).
|
|
10
|
+
|
|
11
|
+
**Binding** — The link between an exchange and a queue, specifying which routing key patterns match.
|
|
12
|
+
|
|
13
|
+
**Session** — `BugBunny::Session`. A wrapper around a Bunny channel. Declares exchanges and queues, caches their AMQP objects, and handles double-checked locking for thread safety.
|
|
14
|
+
|
|
15
|
+
**Producer** — `BugBunny::Producer`. Publishes messages to RabbitMQ. Implements both RPC (blocking) and fire-and-forget (non-blocking) modes. One Producer is cached per connection slot.
|
|
16
|
+
|
|
17
|
+
**Consumer** — `BugBunny::Consumer`. The subscribe loop that runs in a worker process. Receives messages, routes them through middleware and the router, dispatches to a controller, and sends RPC replies.
|
|
18
|
+
|
|
19
|
+
**RPC (Remote Procedure Call)** — A synchronous request-response pattern over RabbitMQ. The producer publishes with `reply_to: 'amq.rabbitmq.reply-to'` and blocks on a `Concurrent::IVar` until the consumer replies to that pseudo-queue.
|
|
20
|
+
|
|
21
|
+
**Fire-and-forget** — An asynchronous publish with no reply expected. The producer does not block. Used for events, notifications, and side effects.
|
|
22
|
+
|
|
23
|
+
**Direct Reply-to** — A RabbitMQ pseudo-queue (`amq.rabbitmq.reply-to`) that allows RPC replies without declaring a temporary queue. BugBunny uses this for all RPC responses.
|
|
24
|
+
|
|
25
|
+
**Controller** — `BugBunny::Controller`. A class that handles an incoming routed message. Mirrors Rails ActionController: supports `before_action`, `around_action`, `after_action`, `rescue_from`, `params`, and `render`.
|
|
26
|
+
|
|
27
|
+
**Resource** — `BugBunny::Resource`. An ActiveRecord-like ORM over AMQP. Wraps CRUD operations as RPC calls. Used by the publishing side to interact with a remote service.
|
|
28
|
+
|
|
29
|
+
**Client** — `BugBunny::Client`. High-level API for the publisher side. Implements the Onion Middleware (Faraday-style) pattern around the Producer.
|
|
30
|
+
|
|
31
|
+
**ConsumerMiddleware** — A pipeline of middleware objects that runs before each message is dispatched to a controller. Used for cross-cutting concerns: tracing, authentication, logging.
|
|
32
|
+
|
|
33
|
+
**ConnectionPool** — A `connection_pool` gem pool of Bunny connections. Each slot holds one connection, one Session, and one Producer. `Resource.connection_pool` must be set before making AMQP calls.
|
|
34
|
+
|
|
35
|
+
**IVar (Ivar / Concurrent::IVar)** — An immutable variable from the `concurrent-ruby` gem. Used to block the calling thread until the RPC reply arrives or the timeout expires.
|
|
36
|
+
|
|
37
|
+
**health_check_file** — A file path that BugBunny touches periodically (every `health_check_interval` seconds) after verifying the RabbitMQ connection. Used as a liveness probe in Docker/Kubernetes.
|
|
38
|
+
|
|
39
|
+
**rpc_reply_headers** — A `Proc` returning a `Hash` of AMQP headers injected into every RPC reply. Used to propagate trace context from consumer back to producer.
|
|
40
|
+
|
|
41
|
+
**on_rpc_reply** — A `Proc` called on the producer's thread when an RPC reply arrives, with the reply's AMQP headers. Used to hydrate trace context in the calling service.
|
|
42
|
+
|
|
43
|
+
**Namespace routing** — A DSL feature that prefixes a group of routes with a module namespace and a path prefix. Example: `namespace :admin` generates routes under `Admin::Controllers::*Controller` with path prefix `admin/`.
|
|
44
|
+
|
|
45
|
+
**param_key** — The root key used to wrap the payload in POST/PUT requests. Defaults to the singularized resource name (e.g., `user` for `UsersResource`).
|
data/docs/concepts.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Concepts
|
|
2
|
+
|
|
3
|
+
## What is BugBunny?
|
|
4
|
+
|
|
5
|
+
BugBunny is a Ruby gem that implements a RESTful routing layer over AMQP (RabbitMQ). It lets microservices communicate through RabbitMQ using familiar HTTP-like patterns: verbs (GET, POST, PUT, DELETE), controllers, declarative routes, synchronous RPC, and fire-and-forget publishing.
|
|
6
|
+
|
|
7
|
+
**Problem it solves:** Direct HTTP coupling between microservices creates a tight dependency graph — if Service B is down, Service A fails immediately. RabbitMQ as a message bus decouples availability, but raw AMQP APIs are low-level and verbose. BugBunny gives you the ergonomics of a web framework on top of the reliability of a message broker.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## AMQP in 5 Minutes
|
|
12
|
+
|
|
13
|
+
AMQP (Advanced Message Queuing Protocol) is the protocol RabbitMQ implements. The key concepts:
|
|
14
|
+
|
|
15
|
+
**Exchange** — Receives messages from producers and routes them to queues based on rules. Types:
|
|
16
|
+
- `direct` — Routes to queues whose binding key exactly matches the routing key.
|
|
17
|
+
- `topic` — Routes using wildcard patterns (`orders.*`, `#.error`).
|
|
18
|
+
- `fanout` — Broadcasts to all bound queues regardless of routing key.
|
|
19
|
+
|
|
20
|
+
**Queue** — Stores messages until a consumer picks them up. Durable queues survive broker restarts.
|
|
21
|
+
|
|
22
|
+
**Routing Key** — A string the producer attaches to the message. The exchange uses it to decide which queues receive the message.
|
|
23
|
+
|
|
24
|
+
**Binding** — A link between an exchange and a queue, optionally with a routing key pattern.
|
|
25
|
+
|
|
26
|
+
In BugBunny, the **path** of the message (e.g., `nodes/123`) travels inside the AMQP `type` header. The routing key determines *which service* receives the message; the path determines *which controller and action* handles it inside that service.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Architecture
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
Service A (Producer)
|
|
34
|
+
BugBunny::Resource.find(id)
|
|
35
|
+
BugBunny::Client#request(path)
|
|
36
|
+
|
|
|
37
|
+
v
|
|
38
|
+
BugBunny::Producer
|
|
39
|
+
| publishes to exchange with:
|
|
40
|
+
| routing_key: 'nodes'
|
|
41
|
+
| type header: 'nodes/123'
|
|
42
|
+
| reply_to: 'amq.rabbitmq.reply-to' (RPC only)
|
|
43
|
+
| correlation_id: 'abc-123' (RPC only)
|
|
44
|
+
v
|
|
45
|
+
[ RabbitMQ Exchange ]
|
|
46
|
+
|
|
|
47
|
+
v
|
|
48
|
+
[ RabbitMQ Queue ]
|
|
49
|
+
|
|
|
50
|
+
v
|
|
51
|
+
Service B (Consumer)
|
|
52
|
+
BugBunny::Consumer (subscribe loop)
|
|
53
|
+
|
|
|
54
|
+
v
|
|
55
|
+
ConsumerMiddleware::Stack
|
|
56
|
+
(tracing, auth, etc.)
|
|
57
|
+
|
|
|
58
|
+
v
|
|
59
|
+
Router (BugBunny.routes)
|
|
60
|
+
matches 'GET nodes/123' → NodesController#show
|
|
61
|
+
|
|
|
62
|
+
v
|
|
63
|
+
NodesController#show
|
|
64
|
+
render status: :ok, json: node
|
|
65
|
+
|
|
|
66
|
+
v (RPC only)
|
|
67
|
+
reply → amq.rabbitmq.reply-to
|
|
68
|
+
|
|
|
69
|
+
v
|
|
70
|
+
Service A (unblocked)
|
|
71
|
+
future.value → { body: {...}, headers: {...} }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## RPC vs Fire-and-Forget
|
|
77
|
+
|
|
78
|
+
BugBunny supports two communication patterns. Choosing the right one matters for system design.
|
|
79
|
+
|
|
80
|
+
### Synchronous RPC (`:rpc`)
|
|
81
|
+
|
|
82
|
+
The producer blocks until the consumer replies. Uses RabbitMQ's `amq.rabbitmq.reply-to` pseudo-queue — no temporary queues are created.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
response = client.request('users/42', method: :get)
|
|
86
|
+
# blocks here until the consumer sends back a reply (or timeout)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Use when:** Service A needs the result to continue. Example: fetching user data before building a response, validating inventory before placing an order.
|
|
90
|
+
|
|
91
|
+
**Timeout:** Configurable via `config.rpc_timeout`. Raises `BugBunny::RequestTimeout` if the consumer does not reply in time.
|
|
92
|
+
|
|
93
|
+
**Cost:** Ties up a thread in Service A for the duration of the call.
|
|
94
|
+
|
|
95
|
+
### Fire-and-Forget (`:publish`)
|
|
96
|
+
|
|
97
|
+
The producer publishes and continues immediately. No reply is expected or waited for.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
client.publish('events', body: { type: 'order.placed', order_id: 99 })
|
|
101
|
+
# returns immediately with { 'status' => 202 }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Use when:** Service A does not need a result. Example: emitting audit events, triggering background jobs, sending notifications.
|
|
105
|
+
|
|
106
|
+
**Cost:** None — but you have no confirmation that the message was processed successfully.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Key Components
|
|
111
|
+
|
|
112
|
+
| Class | Role |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `BugBunny::Configuration` | Global settings. Validates required fields on `BugBunny.configure`. |
|
|
115
|
+
| `BugBunny::Session` | Wraps a Bunny channel. Declares exchanges and queues. Thread-safe with double-checked locking. |
|
|
116
|
+
| `BugBunny::Producer` | Publishes messages. Implements RPC with `Concurrent::IVar`. |
|
|
117
|
+
| `BugBunny::Client` | High-level publisher API. Manages a connection pool and middleware stack. |
|
|
118
|
+
| `BugBunny::Consumer` | Subscribe loop. Routes messages to controllers via `BugBunny.routes`. |
|
|
119
|
+
| `BugBunny::ConsumerMiddleware::Stack` | Pipeline of middlewares executed before `process_message`. |
|
|
120
|
+
| `BugBunny::Controller` | Base class for message handlers. Supports `before_action`, `after_action`, `around_action`, `rescue_from`. |
|
|
121
|
+
| `BugBunny::Resource` | ActiveRecord-like ORM over AMQP. Provides `find`, `where`, `create`, `save`, `destroy`. |
|
|
122
|
+
| `BugBunny::Routing::RouteSet` | Stores and matches declared routes. |
|
|
123
|
+
| `BugBunny::Observability` | Mixin for structured logging. `safe_log` never raises. |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Connection Pool
|
|
128
|
+
|
|
129
|
+
BugBunny uses the `connection_pool` gem to share connections safely across threads (Puma workers, Sidekiq threads).
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
ConnectionPool
|
|
133
|
+
slot 0: Bunny::Session → BugBunny::Session → BugBunny::Producer
|
|
134
|
+
slot 1: Bunny::Session → BugBunny::Session → BugBunny::Producer
|
|
135
|
+
slot N: ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Each pool slot caches its `Session` and `Producer` for the lifetime of the slot. This avoids re-creating AMQP channels (expensive) and prevents the double `basic_consume` error that would occur if a new Producer were created on a reused channel.
|
|
139
|
+
|
|
140
|
+
Thread safety is guaranteed by `ConnectionPool` itself: each slot is used by one thread at a time, so no additional mutex is needed at the Session or Producer level.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Controllers
|
|
2
|
+
|
|
3
|
+
Controllers receive routed messages and produce responses. They follow the same lifecycle as ActionController in Rails.
|
|
4
|
+
|
|
5
|
+
## Defining a Controller
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
module BugBunny
|
|
9
|
+
module Controllers
|
|
10
|
+
class NodesController < BugBunny::Controller
|
|
11
|
+
def index
|
|
12
|
+
nodes = Node.all
|
|
13
|
+
render status: :ok, json: nodes.map(&:as_json)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
node = Node.find(params[:id])
|
|
18
|
+
render status: :ok, json: node.as_json
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
node = Node.new(node_params)
|
|
23
|
+
if node.save
|
|
24
|
+
render status: :created, json: node.as_json
|
|
25
|
+
else
|
|
26
|
+
render status: :unprocessable_entity, json: { errors: node.errors }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The controller namespace defaults to `BugBunny::Controllers`. Override it globally with `config.controller_namespace`, or per route group with `namespace` blocks in the DSL.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## params
|
|
39
|
+
|
|
40
|
+
`params` merges path parameters, query string parameters, and body parameters into a single `HashWithIndifferentAccess`:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Message for PUT nodes/42?verbose=true with body { "node": { "status": "active" } }
|
|
44
|
+
params[:id] # => "42" (from path)
|
|
45
|
+
params[:verbose] # => "true" (from query string)
|
|
46
|
+
params[:node][:status] # => "active" (from JSON body)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Use strong-parameters style to extract what you need:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
def node_params
|
|
53
|
+
params.require(:node).permit(:name, :status)
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## before_action
|
|
60
|
+
|
|
61
|
+
Runs before the action. Rendering inside a `before_action` halts the chain — the action and `after_action` callbacks do not run.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class ApplicationController < BugBunny::Controller
|
|
65
|
+
before_action :authenticate!
|
|
66
|
+
before_action :set_node, only: [:show, :update, :destroy, :drain]
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def authenticate!
|
|
71
|
+
token = request_headers['X-Service-Token']
|
|
72
|
+
render status: :unauthorized, json: { error: 'Unauthorized' } unless valid_token?(token)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def set_node
|
|
76
|
+
@node = Node.find(params[:id])
|
|
77
|
+
render status: :not_found, json: { error: 'Not found' } unless @node
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## after_action
|
|
85
|
+
|
|
86
|
+
Runs after the action completes successfully. Skipped if a `before_action` halted the chain or if the action raised an exception — same behavior as Rails.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class NodesController < ApplicationController
|
|
90
|
+
after_action :emit_audit_event, only: [:create, :update, :destroy]
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def emit_audit_event
|
|
95
|
+
AuditLog.record(action: action_name, node_id: params[:id], actor: current_service)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## around_action
|
|
103
|
+
|
|
104
|
+
Wraps the action. Must call `yield` to execute the inner chain.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class NodesController < ApplicationController
|
|
108
|
+
around_action :with_distributed_lock, only: [:drain]
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def with_distributed_lock
|
|
113
|
+
DistributedLock.acquire("node:#{params[:id]}") { yield }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## rescue_from
|
|
121
|
+
|
|
122
|
+
Catches exceptions raised during the action and maps them to responses. Handlers are inherited and can be defined in a base `ApplicationController`.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class ApplicationController < BugBunny::Controller
|
|
126
|
+
rescue_from ActiveRecord::RecordNotFound do |e|
|
|
127
|
+
render status: :not_found, json: { error: e.message }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
rescue_from ActiveRecord::RecordInvalid do |e|
|
|
131
|
+
render status: :unprocessable_entity, json: { errors: e.record.errors.full_messages }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
rescue_from StandardError do |e|
|
|
135
|
+
logger.error("Unhandled error: #{e.class} — #{e.message}")
|
|
136
|
+
render status: :internal_server_error, json: { error: 'Internal server error' }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## render
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
render status: :ok, json: resource.as_json
|
|
147
|
+
render status: :created, json: { id: record.id }
|
|
148
|
+
render status: :no_content, json: nil
|
|
149
|
+
render status: :unprocessable_entity, json: { errors: object.errors }
|
|
150
|
+
render status: :not_found, json: { error: 'Not found' }
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Accepts any Rack status symbol (`:ok`, `:created`, `:not_found`, etc.) or an integer status code.
|
|
154
|
+
|
|
155
|
+
### Adding response headers
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Per-response headers (merged, non-destructive)
|
|
159
|
+
render status: :ok, json: data, headers: { 'X-Request-Id' => request_id }
|
|
160
|
+
|
|
161
|
+
# Headers set for the lifetime of the action (accessible via response_headers)
|
|
162
|
+
def show
|
|
163
|
+
response_headers['X-Cache'] = 'HIT'
|
|
164
|
+
render status: :ok, json: Node.find(params[:id]).as_json
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Accessing AMQP headers
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
def create
|
|
174
|
+
trace_id = request_headers['X-Trace-Id']
|
|
175
|
+
# ...
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`request_headers` returns the raw AMQP headers hash from `properties.headers`.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## log_tags
|
|
184
|
+
|
|
185
|
+
Injects contextual tags into all log lines produced within the action's execution:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class NodesController < ApplicationController
|
|
189
|
+
log_tag { params[:id] }
|
|
190
|
+
log_tag { current_tenant }
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Works with Rails' `ActiveSupport::TaggedLogging`.
|