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,119 @@
|
|
|
1
|
+
# Client Middleware
|
|
2
|
+
|
|
3
|
+
The client middleware stack wraps outgoing requests in an onion (Faraday-style). Each middleware can inspect and modify the request before it reaches the producer, and inspect the response on the way back.
|
|
4
|
+
|
|
5
|
+
## Built-in Middlewares
|
|
6
|
+
|
|
7
|
+
### `BugBunny::Middleware::RaiseError`
|
|
8
|
+
|
|
9
|
+
Maps HTTP-like status codes in the response to Ruby exceptions:
|
|
10
|
+
|
|
11
|
+
| Status | Exception |
|
|
12
|
+
|--------|-----------|
|
|
13
|
+
| 400 | `BugBunny::BadRequest` |
|
|
14
|
+
| 401 | `BugBunny::Unauthorized` |
|
|
15
|
+
| 403 | `BugBunny::Forbidden` |
|
|
16
|
+
| 404 | `BugBunny::NotFound` |
|
|
17
|
+
| 409 | `BugBunny::Conflict` |
|
|
18
|
+
| 422 | `BugBunny::UnprocessableEntity` |
|
|
19
|
+
| 4xx | `BugBunny::ClientError` |
|
|
20
|
+
| 500 | `BugBunny::InternalServerError` |
|
|
21
|
+
| 5xx | `BugBunny::ServerError` |
|
|
22
|
+
| Timeout| `BugBunny::RequestTimeout` |
|
|
23
|
+
|
|
24
|
+
### `BugBunny::Middleware::JsonResponse`
|
|
25
|
+
|
|
26
|
+
Parses the response body from JSON and returns a `HashWithIndifferentAccess`. Without this middleware, the response body is a raw String.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Using Middlewares with Client
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
pool = ConnectionPool.new(size: 5, timeout: 5) { BugBunny.create_connection }
|
|
34
|
+
client = BugBunny::Client.new(pool: pool) do |stack|
|
|
35
|
+
stack.use BugBunny::Middleware::RaiseError
|
|
36
|
+
stack.use BugBunny::Middleware::JsonResponse
|
|
37
|
+
stack.use MyLoggingMiddleware
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Middlewares execute in the order they are registered (FIFO). `RaiseError` and `JsonResponse` should be registered in that order so that `RaiseError` sees the parsed body.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Writing a Custom Middleware
|
|
46
|
+
|
|
47
|
+
Inherit from `BugBunny::Middleware::Base` and implement `call`:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class RequestLoggingMiddleware < BugBunny::Middleware::Base
|
|
51
|
+
def call(request)
|
|
52
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
53
|
+
response = app.call(request)
|
|
54
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
55
|
+
|
|
56
|
+
Rails.logger.info("amqp_request path=#{request.path} method=#{request.method} duration_s=#{duration.round(4)}")
|
|
57
|
+
response
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`app.call(request)` invokes the next middleware in the stack (or the producer at the end of the chain). The return value is the response hash `{ 'status' => Integer, 'body' => ..., 'headers' => Hash }`.
|
|
63
|
+
|
|
64
|
+
### Modifying the request
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class InjectTraceHeaderMiddleware < BugBunny::Middleware::Base
|
|
68
|
+
def call(request)
|
|
69
|
+
request.headers['X-Trace-Id'] = MyTracer.current_trace_id
|
|
70
|
+
app.call(request)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Modifying the response
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class ResponseCachingMiddleware < BugBunny::Middleware::Base
|
|
79
|
+
def call(request)
|
|
80
|
+
cached = Cache.get(request.path)
|
|
81
|
+
return cached if cached
|
|
82
|
+
|
|
83
|
+
response = app.call(request)
|
|
84
|
+
Cache.set(request.path, response, ttl: 30) if response['status'] == 200
|
|
85
|
+
response
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Middlewares on Resource
|
|
93
|
+
|
|
94
|
+
`BugBunny::Resource` uses `RaiseError` and `JsonResponse` by default. Add custom middlewares via the class-level DSL:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class RemoteNode < BugBunny::Resource
|
|
98
|
+
client_middleware do |stack|
|
|
99
|
+
stack.use RequestLoggingMiddleware
|
|
100
|
+
stack.use InjectTraceHeaderMiddleware
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Custom middlewares are injected after the core ones, so `RaiseError` and `JsonResponse` always run first.
|
|
106
|
+
|
|
107
|
+
Middlewares defined in a parent class are inherited:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
class ApplicationResource < BugBunny::Resource
|
|
111
|
+
client_middleware do |stack|
|
|
112
|
+
stack.use InjectTraceHeaderMiddleware
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class RemoteNode < ApplicationResource
|
|
117
|
+
# inherits InjectTraceHeaderMiddleware
|
|
118
|
+
end
|
|
119
|
+
```
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Consumer Middleware
|
|
2
|
+
|
|
3
|
+
The consumer middleware stack runs before every message reaches the router. It is the right place for cross-cutting concerns: distributed tracing, authentication, audit logging, rate limiting.
|
|
4
|
+
|
|
5
|
+
## Execution Order
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
RabbitMQ message arrives
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
ConsumerMiddleware::Stack
|
|
12
|
+
Middleware A (first registered)
|
|
13
|
+
Middleware B
|
|
14
|
+
Middleware C
|
|
15
|
+
process_message (router → controller → action)
|
|
16
|
+
Middleware C (post-processing)
|
|
17
|
+
Middleware B (post-processing)
|
|
18
|
+
Middleware A (post-processing)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Middlewares execute FIFO. If no middlewares are registered, the overhead is zero.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Writing a Middleware
|
|
26
|
+
|
|
27
|
+
Inherit from `BugBunny::ConsumerMiddleware::Base` and implement `call`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class TracingMiddleware < BugBunny::ConsumerMiddleware::Base
|
|
31
|
+
def call(delivery_info, properties, body)
|
|
32
|
+
trace_id = properties.headers&.dig('X-Trace-Id') || SecureRandom.uuid
|
|
33
|
+
MyTracer.with_trace(trace_id) do
|
|
34
|
+
@app.call(delivery_info, properties, body)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`@app.call(delivery_info, properties, body)` passes control to the next middleware. Always call it (unless you intentionally want to halt processing).
|
|
41
|
+
|
|
42
|
+
### Available data
|
|
43
|
+
|
|
44
|
+
| Argument | Type | Contents |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `delivery_info` | `Bunny::DeliveryInfo` | `routing_key`, `exchange`, `delivery_tag`, `redelivered` |
|
|
47
|
+
| `properties` | `Bunny::MessageProperties` | `headers` (custom AMQP headers), `correlation_id`, `reply_to`, `content_type` |
|
|
48
|
+
| `body` | `String` | Raw message payload (typically JSON) |
|
|
49
|
+
|
|
50
|
+
### Post-processing
|
|
51
|
+
|
|
52
|
+
Code after `@app.call` runs once the message has been fully processed (controller action completed):
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class AuditMiddleware < BugBunny::ConsumerMiddleware::Base
|
|
56
|
+
def call(delivery_info, properties, body)
|
|
57
|
+
@app.call(delivery_info, properties, body)
|
|
58
|
+
rescue => e
|
|
59
|
+
AuditLog.record_failure(routing_key: delivery_info.routing_key, error: e.class.name)
|
|
60
|
+
raise
|
|
61
|
+
ensure
|
|
62
|
+
AuditLog.record_received(routing_key: delivery_info.routing_key)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Registering Middlewares
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# After BugBunny.configure
|
|
73
|
+
BugBunny.consumer_middlewares.use TracingMiddleware
|
|
74
|
+
BugBunny.consumer_middlewares.use AuditMiddleware
|
|
75
|
+
BugBunny.consumer_middlewares.use AuthenticationMiddleware
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Registrations are thread-safe. You can register middlewares at any point before the Consumer starts subscribing.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Auto-registration from External Gems
|
|
83
|
+
|
|
84
|
+
Integration gems can register themselves transparently when required, without the user modifying the `configure` block:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# lib/my_tracing_gem/bug_bunny.rb
|
|
88
|
+
require 'my_tracing_gem/bug_bunny/consumer_middleware'
|
|
89
|
+
BugBunny.consumer_middlewares.use MyTracingGem::BugBunny::ConsumerMiddleware
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The user only needs:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require 'my_tracing_gem/bug_bunny'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Halting Message Processing
|
|
101
|
+
|
|
102
|
+
To reject a message without routing it (e.g., authentication failure):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class AuthMiddleware < BugBunny::ConsumerMiddleware::Base
|
|
106
|
+
def call(delivery_info, properties, body)
|
|
107
|
+
token = properties.headers&.dig('X-Service-Token')
|
|
108
|
+
unless TokenValidator.valid?(token)
|
|
109
|
+
# Do not call @app — message is effectively dropped from this consumer's perspective
|
|
110
|
+
# The channel will nack/reject it based on the consumer's error handling
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@app.call(delivery_info, properties, body)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Note: halting in a middleware skips all routing and controller logic, but the message is still acknowledged at the AMQP level (the Consumer's normal ack happens in `process_message`, which was never reached). If you need to nack/reject, interact with `delivery_info.delivery_tag` directly — but this is an advanced use case.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Inspecting the Stack
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
BugBunny.consumer_middlewares.empty? # => false if any middleware registered
|
|
127
|
+
```
|
data/docs/howto/rails.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Rails Setup
|
|
2
|
+
|
|
3
|
+
Complete integration guide for BugBunny in a Rails application using Puma and/or Sidekiq.
|
|
4
|
+
|
|
5
|
+
## Gemfile
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem 'bug_bunny'
|
|
9
|
+
gem 'connection_pool'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Generator
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
rails generate bug_bunny:install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Creates `config/initializers/bug_bunny.rb` with a commented template.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Initializer
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/initializers/bug_bunny.rb
|
|
26
|
+
|
|
27
|
+
BugBunny.configure do |config|
|
|
28
|
+
config.host = ENV.fetch('RABBITMQ_HOST', 'localhost')
|
|
29
|
+
config.port = ENV.fetch('RABBITMQ_PORT', '5672').to_i
|
|
30
|
+
config.username = ENV.fetch('RABBITMQ_USER', 'guest')
|
|
31
|
+
config.password = ENV.fetch('RABBITMQ_PASS', 'guest')
|
|
32
|
+
config.vhost = ENV.fetch('RABBITMQ_VHOST', '/')
|
|
33
|
+
|
|
34
|
+
config.rpc_timeout = 30
|
|
35
|
+
config.max_reconnect_attempts = 10
|
|
36
|
+
config.max_reconnect_interval = 60
|
|
37
|
+
config.network_recovery_interval = 5
|
|
38
|
+
|
|
39
|
+
config.exchange_options = { durable: true }
|
|
40
|
+
config.queue_options = { durable: true }
|
|
41
|
+
|
|
42
|
+
config.logger = Rails.logger
|
|
43
|
+
|
|
44
|
+
# Kubernetes / Docker Swarm liveness probe
|
|
45
|
+
config.health_check_file = Rails.root.join('tmp', 'bug_bunny_health').to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Shared connection pool — size should match Puma/Sidekiq thread count
|
|
49
|
+
BUG_BUNNY_POOL = ConnectionPool.new(
|
|
50
|
+
size: ENV.fetch('RAILS_MAX_THREADS', 5).to_i,
|
|
51
|
+
timeout: 5
|
|
52
|
+
) do
|
|
53
|
+
BugBunny.create_connection
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Make the pool available to all Resource classes
|
|
57
|
+
BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Routes
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config/initializers/bug_bunny_routes.rb
|
|
66
|
+
BugBunny.routes.draw do
|
|
67
|
+
resources :users
|
|
68
|
+
resources :orders do
|
|
69
|
+
member { post :cancel }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Keep routes in a dedicated initializer so they load independently from `config/routes.rb`.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Directory Layout
|
|
79
|
+
|
|
80
|
+
The Rails generator configures Zeitwerk to autoload `app/rabbit`:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
app/
|
|
84
|
+
rabbit/
|
|
85
|
+
controllers/ # BugBunny controllers (loaded under BugBunny::Controllers)
|
|
86
|
+
application_controller.rb
|
|
87
|
+
users_controller.rb
|
|
88
|
+
workers/ # Consumer processes (optional)
|
|
89
|
+
inventory_worker.rb
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Or use the configurable controller namespace:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
config.controller_namespace = 'Rabbit::Controllers'
|
|
96
|
+
# → app/rabbit/controllers/ maps to Rabbit::Controllers
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Consumer Workers
|
|
102
|
+
|
|
103
|
+
The Consumer is a blocking subscribe loop. Run it in a dedicated process (not inside Puma).
|
|
104
|
+
|
|
105
|
+
### Rake task
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# lib/tasks/rabbit.rake
|
|
109
|
+
namespace :rabbit do
|
|
110
|
+
desc 'Start the RabbitMQ consumer'
|
|
111
|
+
task inventory: :environment do
|
|
112
|
+
consumer = BugBunny::Consumer.new
|
|
113
|
+
trap('TERM') { consumer.shutdown; exit }
|
|
114
|
+
trap('INT') { consumer.shutdown; exit }
|
|
115
|
+
|
|
116
|
+
consumer.subscribe(
|
|
117
|
+
queue_name: 'inventory_queue',
|
|
118
|
+
exchange_name: 'inventory_exchange',
|
|
119
|
+
routing_key: 'inventory',
|
|
120
|
+
block: true
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bundle exec rake rabbit:inventory
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Dockerfile entrypoint (separate service)
|
|
131
|
+
|
|
132
|
+
```dockerfile
|
|
133
|
+
# Dockerfile.worker
|
|
134
|
+
CMD ["bundle", "exec", "rake", "rabbit:inventory"]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Multiple consumers
|
|
138
|
+
|
|
139
|
+
Run one process per queue. Each process has its own connection pool.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Puma Fork Safety
|
|
144
|
+
|
|
145
|
+
When Puma forks workers, open AMQP connections in the parent process become invalid in children. BugBunny's Railtie handles this automatically for Puma via `on_worker_boot`:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# Railtie registers this automatically:
|
|
149
|
+
Puma::Server.on_worker_boot do
|
|
150
|
+
BugBunny.reconnect! if defined?(BUG_BUNNY_POOL)
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If you use a custom pool variable name, add the hook manually:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# config/puma.rb
|
|
158
|
+
on_worker_boot do
|
|
159
|
+
MY_CUSTOM_POOL.reload_connections { BugBunny.create_connection }
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Sidekiq Integration
|
|
166
|
+
|
|
167
|
+
Sidekiq uses threads, not forks, so no special handling is needed. The shared `BUG_BUNNY_POOL` is thread-safe. Sidekiq jobs can call `BugBunny::Resource` methods directly.
|
|
168
|
+
|
|
169
|
+
Set the pool size to match Sidekiq's concurrency:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
BUG_BUNNY_POOL = ConnectionPool.new(
|
|
173
|
+
size: ENV.fetch('SIDEKIQ_CONCURRENCY', 10).to_i,
|
|
174
|
+
timeout: 5
|
|
175
|
+
) { BugBunny.create_connection }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Health Checks (Kubernetes / Docker Swarm)
|
|
181
|
+
|
|
182
|
+
The Consumer's internal heartbeat timer touches `config.health_check_file` every 30 seconds after verifying the RabbitMQ connection and queue are alive.
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
# docker-compose.yml
|
|
186
|
+
healthcheck:
|
|
187
|
+
test: ["CMD", "test", "-f", "/app/tmp/bug_bunny_health"]
|
|
188
|
+
interval: 60s
|
|
189
|
+
timeout: 5s
|
|
190
|
+
retries: 3
|
|
191
|
+
start_period: 30s # allow time for Rails boot + first heartbeat
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
# Kubernetes
|
|
196
|
+
livenessProbe:
|
|
197
|
+
exec:
|
|
198
|
+
command: ["test", "-f", "/app/tmp/bug_bunny_health"]
|
|
199
|
+
initialDelaySeconds: 30
|
|
200
|
+
periodSeconds: 60
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Graceful Shutdown
|
|
206
|
+
|
|
207
|
+
The `Consumer#shutdown` method stops the heartbeat timer and closes the AMQP channel cleanly. It is also called automatically when `subscribe` exits for any reason.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
consumer = BugBunny::Consumer.new
|
|
211
|
+
trap('TERM') { consumer.shutdown; exit 0 }
|
|
212
|
+
trap('INT') { consumer.shutdown; exit 0 }
|
|
213
|
+
consumer.subscribe(...)
|
|
214
|
+
```
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Resource ORM
|
|
2
|
+
|
|
3
|
+
`BugBunny::Resource` provides an ActiveRecord-like interface for remote services. Each Resource class represents a resource type in another microservice, reachable via RabbitMQ.
|
|
4
|
+
|
|
5
|
+
## Defining a Resource
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class RemoteNode < BugBunny::Resource
|
|
9
|
+
# AMQP infrastructure
|
|
10
|
+
self.exchange = 'inventory_exchange'
|
|
11
|
+
self.exchange_type = 'direct' # default
|
|
12
|
+
self.resource_name = 'nodes' # used as the path prefix and routing key
|
|
13
|
+
|
|
14
|
+
# Typed attributes (ActiveModel::Attributes)
|
|
15
|
+
attribute :name, :string
|
|
16
|
+
attribute :status, :string
|
|
17
|
+
attribute :cpu_cores, :integer
|
|
18
|
+
attribute :active, :boolean
|
|
19
|
+
|
|
20
|
+
# Validations (ActiveModel::Validations)
|
|
21
|
+
validates :name, presence: true
|
|
22
|
+
validates :status, inclusion: { in: %w[pending active draining decommissioned] }
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Connection Pool
|
|
27
|
+
|
|
28
|
+
All Resource classes in a service typically share one pool:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# config/initializers/bug_bunny.rb
|
|
32
|
+
BUG_BUNNY_POOL = ConnectionPool.new(size: 5, timeout: 5) do
|
|
33
|
+
BugBunny.create_connection
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
BugBunny::Resource.connection_pool = BUG_BUNNY_POOL
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Individual classes can override:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
RemoteNode.connection_pool = OTHER_POOL
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## CRUD
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Find by ID — returns nil on 404
|
|
51
|
+
node = RemoteNode.find('node-123')
|
|
52
|
+
|
|
53
|
+
# List all
|
|
54
|
+
nodes = RemoteNode.all
|
|
55
|
+
|
|
56
|
+
# Filter — query params forwarded to the consumer
|
|
57
|
+
nodes = RemoteNode.where(status: 'active')
|
|
58
|
+
nodes = RemoteNode.where(q: { cpu_cores: 4 }, page: 2)
|
|
59
|
+
|
|
60
|
+
# Create
|
|
61
|
+
node = RemoteNode.create(name: 'web-01', status: 'pending')
|
|
62
|
+
node.persisted? # => true if save succeeded
|
|
63
|
+
|
|
64
|
+
# Update
|
|
65
|
+
node = RemoteNode.find('node-123')
|
|
66
|
+
node.status = 'active'
|
|
67
|
+
node.save # PUT nodes/node-123
|
|
68
|
+
|
|
69
|
+
# Update (shorthand)
|
|
70
|
+
node.update(status: 'active', name: 'web-01')
|
|
71
|
+
|
|
72
|
+
# Destroy
|
|
73
|
+
node.destroy # DELETE nodes/node-123
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Typed vs Dynamic Attributes
|
|
79
|
+
|
|
80
|
+
### Typed attributes
|
|
81
|
+
|
|
82
|
+
Declared with `attribute :name, :type`. Benefit from ActiveModel coercions, dirty tracking, and validations:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
attribute :cpu_cores, :integer
|
|
86
|
+
attribute :enabled, :boolean
|
|
87
|
+
attribute :score, :decimal
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Dynamic attributes
|
|
91
|
+
|
|
92
|
+
Any key received from the remote service that is not declared as a typed attribute is stored dynamically and accessible via `method_missing`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
node = RemoteNode.find('node-123')
|
|
96
|
+
node.docker_id # => "abc123xyz" (not declared, but present in the response)
|
|
97
|
+
node.docker_id = 'new-id'
|
|
98
|
+
node.changed? # => true
|
|
99
|
+
node.changed # => ['docker_id']
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Dynamic attributes participate in `changed?`, `changed`, and `changes_to_send` — so they are serialized correctly on `save`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Change Tracking
|
|
107
|
+
|
|
108
|
+
BugBunny::Resource merges ActiveModel::Dirty (for typed attributes) with its own tracking for dynamic attributes.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
node = RemoteNode.find('node-123') # changes cleared after find
|
|
112
|
+
node.changed? # => false
|
|
113
|
+
|
|
114
|
+
node.status = 'draining'
|
|
115
|
+
node.changed? # => true
|
|
116
|
+
node.changed # => ['status']
|
|
117
|
+
|
|
118
|
+
node.save # sends only changed attrs
|
|
119
|
+
node.changed? # => false (cleared after save)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`save` on a new record (not persisted) sends all attributes.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Validations
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
node = RemoteNode.new(name: '', status: 'invalid')
|
|
130
|
+
node.valid? # => false
|
|
131
|
+
node.errors.full_messages
|
|
132
|
+
# => ["Name can't be blank", "Status is not included in the list"]
|
|
133
|
+
|
|
134
|
+
node.save # => false (does not send the request)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Remote validation errors (422 responses) are loaded back into the object:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
node = RemoteNode.create(name: 'duplicate-name')
|
|
141
|
+
node.persisted? # => false
|
|
142
|
+
node.errors[:name] # => ["has already been taken"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Callbacks
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class RemoteNode < BugBunny::Resource
|
|
151
|
+
before_save :normalize_name
|
|
152
|
+
after_create :notify_provisioner
|
|
153
|
+
around_save :with_timing
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def normalize_name
|
|
158
|
+
self.name = name.to_s.downcase.strip
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Available callbacks: `before_save`, `after_save`, `before_create`, `after_create`, `before_update`, `after_update`, `before_destroy`, `after_destroy`.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Dynamic Exchange Configuration with `.with`
|
|
168
|
+
|
|
169
|
+
Override AMQP settings for a single operation without changing the class defaults:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Different exchange for a single call
|
|
173
|
+
RemoteNode.with(exchange: 'us-east-inventory').where(status: 'active')
|
|
174
|
+
|
|
175
|
+
# Different routing key
|
|
176
|
+
RemoteNode.with(routing_key: 'nodes.priority').find('node-123')
|
|
177
|
+
|
|
178
|
+
# Chain is single-use — call .with again for the next operation
|
|
179
|
+
RemoteNode.with(exchange: 'staging').create(name: 'test-node')
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`.with` sets thread-local values that are cleaned up after the single operation completes, even if an exception is raised.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Payload Wrapping
|
|
187
|
+
|
|
188
|
+
By default, `save` wraps the payload under a root key derived from the model name:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# RemoteNode → root key: 'node'
|
|
192
|
+
node.save
|
|
193
|
+
# Sends: { "node" => { "name" => "web-01", "status" => "active" } }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The consumer can then use `params.require(:node)` in the controller. Override the root key:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
self.param_key = 'server'
|
|
200
|
+
```
|