jetstream_bridge 3.0.2 → 4.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 +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +1149 -84
- data/lib/jetstream_bridge/consumer/consumer.rb +174 -6
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/message_processor.rb +41 -7
- data/lib/jetstream_bridge/consumer/middleware.rb +154 -0
- data/lib/jetstream_bridge/core/config.rb +150 -9
- data/lib/jetstream_bridge/core/config_preset.rb +99 -0
- data/lib/jetstream_bridge/core/connection.rb +5 -2
- data/lib/jetstream_bridge/core/connection_factory.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +19 -35
- data/lib/jetstream_bridge/errors.rb +60 -8
- data/lib/jetstream_bridge/models/event.rb +202 -0
- data/lib/jetstream_bridge/{inbox_event.rb → models/inbox_event.rb} +61 -3
- data/lib/jetstream_bridge/{outbox_event.rb → models/outbox_event.rb} +64 -15
- data/lib/jetstream_bridge/models/publish_result.rb +64 -0
- data/lib/jetstream_bridge/models/subject.rb +53 -2
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +163 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +238 -19
- data/lib/jetstream_bridge/test_helpers.rb +275 -0
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +178 -3
- data/lib/tasks/yard.rake +18 -0
- metadata +11 -4
data/README.md
CHANGED
|
@@ -31,53 +31,144 @@
|
|
|
31
31
|
</p>
|
|
32
32
|
|
|
33
33
|
<p align="center">
|
|
34
|
+
<a href="#-why-jetstream-bridge">Why?</a> •
|
|
34
35
|
<a href="#-features">Features</a> •
|
|
35
|
-
<a href="#-
|
|
36
|
-
<a href="#-
|
|
37
|
-
<a href="#-
|
|
36
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
37
|
+
<a href="#-documentation">Documentation</a> •
|
|
38
|
+
<a href="#-contributing">Contributing</a>
|
|
38
39
|
</p>
|
|
39
40
|
|
|
40
41
|
---
|
|
41
42
|
|
|
43
|
+
## 🎯 Why JetStream Bridge?
|
|
44
|
+
|
|
45
|
+
Building event-driven systems with NATS JetStream is powerful, but comes with challenges:
|
|
46
|
+
|
|
47
|
+
* **Reliability**: How do you guarantee messages aren't lost during deploys or network failures?
|
|
48
|
+
* **Idempotency**: How do you prevent duplicate processing when messages are redelivered?
|
|
49
|
+
* **Stream Management**: How do you avoid "subjects overlap" errors in production?
|
|
50
|
+
* **Monitoring**: How do you know if your consumers are healthy and processing messages?
|
|
51
|
+
* **Rails Integration**: How do you integrate cleanly with ActiveRecord transactions?
|
|
52
|
+
|
|
53
|
+
**JetStream Bridge solves these problems** with production-tested patterns:
|
|
54
|
+
|
|
55
|
+
* ✅ **Transactional Outbox** - Never lose events, even if NATS is down
|
|
56
|
+
* ✅ **Idempotent Inbox** - Process each message exactly once, safely
|
|
57
|
+
* ✅ **Automatic Stream Provisioning** - No more manual stream management or overlap conflicts
|
|
58
|
+
* ✅ **Built-in Health Checks** - K8s-ready monitoring endpoints
|
|
59
|
+
* ✅ **Rails-Native** - Works seamlessly with ActiveRecord and Rails conventions
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
42
63
|
## ✨ Features
|
|
43
64
|
|
|
44
65
|
### Core Capabilities
|
|
45
66
|
|
|
46
|
-
* 🔌 Simple
|
|
47
|
-
* 🛡 **Outbox**
|
|
48
|
-
* 🧨 **DLQ**
|
|
49
|
-
* ⚙️ Durable
|
|
50
|
-
* 🎯 Clear **source/destination
|
|
51
|
-
* 🧱 **Overlap-safe stream
|
|
52
|
-
* 🚂 **Rails generators**
|
|
53
|
-
* ⚡️ **Eager-loaded models
|
|
54
|
-
* 📊 Configurable logging with sensible defaults
|
|
55
|
-
|
|
56
|
-
### Production-Ready
|
|
57
|
-
|
|
58
|
-
* 🏥 **
|
|
59
|
-
* 🔄 **
|
|
60
|
-
* 🔒 **Race condition protection** - Pessimistic locking
|
|
61
|
-
* 🛡️ **Transaction safety** - All database operations
|
|
62
|
-
* 🎯 **Subject validation** -
|
|
63
|
-
* 🚦 **Graceful shutdown** -
|
|
64
|
-
* 📈 **
|
|
65
|
-
* 🎨 **Value objects** - Type-safe domain models for events and subjects
|
|
66
|
-
* 🏗️ **SOLID architecture** - Clean separation of concerns and dependency injection
|
|
67
|
+
* 🔌 **Simple Publisher and Consumer interfaces** - Write event-driven code in minutes with intuitive APIs that abstract NATS complexity
|
|
68
|
+
* 🛡 **Outbox & Inbox patterns (opt-in)** - Guarantee exactly-once delivery with reliable send (Outbox) and idempotent receive (Inbox)
|
|
69
|
+
* 🧨 **Dead Letter Queue (DLQ)** - Isolate and triage poison messages automatically instead of blocking your entire pipeline
|
|
70
|
+
* ⚙️ **Durable pull subscriptions** - Never lose messages with configurable backoff strategies and max delivery attempts
|
|
71
|
+
* 🎯 **Clear subject conventions** - Organized source/destination routing that scales across multiple services
|
|
72
|
+
* 🧱 **Overlap-safe stream provisioning** - Automatically prevents "subjects overlap" errors with intelligent conflict detection
|
|
73
|
+
* 🚂 **Rails generators included** - Generate initializers, migrations, and health checks with a single command
|
|
74
|
+
* ⚡️ **Zero-downtime deploys** - Eager-loaded models via Railtie ensure production stability
|
|
75
|
+
* 📊 **Observable by default** - Configurable logging with sensible defaults for debugging and monitoring
|
|
76
|
+
|
|
77
|
+
### Production-Ready Reliability
|
|
78
|
+
|
|
79
|
+
* 🏥 **Built-in health checks** - Monitor NATS connection, stream status, and configuration for K8s readiness/liveness probes
|
|
80
|
+
* 🔄 **Automatic reconnection** - Recover from network failures and NATS restarts without manual intervention
|
|
81
|
+
* 🔒 **Race condition protection** - Pessimistic locking prevents duplicate publishes in high-concurrency scenarios
|
|
82
|
+
* 🛡️ **Transaction safety** - All database operations are atomic with automatic rollback on failures
|
|
83
|
+
* 🎯 **Subject validation** - Catch configuration errors early by preventing NATS wildcards where they don't belong
|
|
84
|
+
* 🚦 **Graceful shutdown** - Proper signal handling and message draining prevent data loss during deploys
|
|
85
|
+
* 📈 **Pluggable retry strategies** - Choose exponential or linear backoff, or implement your own custom strategy
|
|
67
86
|
|
|
68
87
|
---
|
|
69
88
|
|
|
70
|
-
##
|
|
89
|
+
## 🚀 Quick Start
|
|
90
|
+
|
|
91
|
+
### 1. Install the Gem
|
|
71
92
|
|
|
72
93
|
```ruby
|
|
73
94
|
# Gemfile
|
|
74
|
-
gem "jetstream_bridge", "~>
|
|
95
|
+
gem "jetstream_bridge", "~> 4.0"
|
|
75
96
|
```
|
|
76
97
|
|
|
77
98
|
```bash
|
|
78
99
|
bundle install
|
|
79
100
|
```
|
|
80
101
|
|
|
102
|
+
### 2. Generate Configuration and Migrations
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Creates initializer and migrations
|
|
106
|
+
bin/rails g jetstream_bridge:install
|
|
107
|
+
|
|
108
|
+
# Run migrations
|
|
109
|
+
bin/rails db:migrate
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 3. Configure Your Application
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# config/initializers/jetstream_bridge.rb
|
|
116
|
+
JetstreamBridge.configure do |config|
|
|
117
|
+
config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
|
|
118
|
+
config.env = ENV.fetch("RAILS_ENV", "development")
|
|
119
|
+
config.app_name = "my_app"
|
|
120
|
+
config.destination_app = "other_app" # Required: The app you're communicating with
|
|
121
|
+
|
|
122
|
+
# Enable reliability features (recommended for production)
|
|
123
|
+
config.use_outbox = true # Transactional outbox pattern
|
|
124
|
+
config.use_inbox = true # Idempotent message processing
|
|
125
|
+
config.use_dlq = true # Dead letter queue for failed messages
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 4. Publish Your First Event
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# In your Rails application
|
|
133
|
+
JetstreamBridge.publish(
|
|
134
|
+
resource_type: "user",
|
|
135
|
+
event_type: "created",
|
|
136
|
+
payload: { id: user.id, email: user.email }
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 5. Consume Events
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# Create a consumer (e.g., in a rake task or separate process)
|
|
144
|
+
consumer = JetstreamBridge::Consumer.new do |event|
|
|
145
|
+
user_data = event.payload
|
|
146
|
+
# Your idempotent business logic here
|
|
147
|
+
User.find_or_create_by(id: user_data.id) do |user|
|
|
148
|
+
user.email = user_data.email
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
consumer.run! # Starts consuming messages
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
That's it! You're now publishing and consuming events with JetStream.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 📖 Documentation
|
|
160
|
+
|
|
161
|
+
### Table of Contents
|
|
162
|
+
|
|
163
|
+
* [Installation & Setup](#-rails-generators--rake-tasks)
|
|
164
|
+
* [Configuration](#-configuration)
|
|
165
|
+
* [Publishing Events](#-publish-events)
|
|
166
|
+
* [Consuming Events](#-consume-events)
|
|
167
|
+
* [Database Setup](#-database-setup-inbox--outbox)
|
|
168
|
+
* [Stream Topology](#-stream-topology-auto-ensure-and-overlap-safe)
|
|
169
|
+
* [Operations Guide](#-operations-guide)
|
|
170
|
+
* [Troubleshooting](#-troubleshooting)
|
|
171
|
+
|
|
81
172
|
---
|
|
82
173
|
|
|
83
174
|
## 🧰 Rails Generators & Rake Tasks
|
|
@@ -129,59 +220,178 @@ bin/rake jetstream_bridge:debug
|
|
|
129
220
|
|
|
130
221
|
---
|
|
131
222
|
|
|
132
|
-
## 🔧
|
|
223
|
+
## 🔧 Configuration
|
|
224
|
+
|
|
225
|
+
### Basic Configuration
|
|
133
226
|
|
|
134
227
|
```ruby
|
|
135
228
|
# config/initializers/jetstream_bridge.rb
|
|
136
229
|
JetstreamBridge.configure do |config|
|
|
137
|
-
#
|
|
138
|
-
config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
|
|
139
|
-
config.env = ENV.fetch("NATS_ENV", "development")
|
|
140
|
-
config.app_name = ENV.fetch("APP_NAME", "app")
|
|
141
|
-
config.destination_app = ENV["DESTINATION_APP"] # required
|
|
230
|
+
# === Required Settings ===
|
|
142
231
|
|
|
143
|
-
#
|
|
144
|
-
config.
|
|
145
|
-
config.ack_wait = "30s"
|
|
146
|
-
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
232
|
+
# NATS server URLs (comma-separated for multiple servers)
|
|
233
|
+
config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
|
|
147
234
|
|
|
148
|
-
#
|
|
235
|
+
# Environment namespace (development, staging, production)
|
|
236
|
+
config.env = ENV.fetch("RAILS_ENV", "development")
|
|
237
|
+
|
|
238
|
+
# Your application name (used in subject routing)
|
|
239
|
+
config.app_name = ENV.fetch("APP_NAME", "my_app")
|
|
240
|
+
|
|
241
|
+
# The application you're communicating with (REQUIRED)
|
|
242
|
+
config.destination_app = ENV.fetch("DESTINATION_APP")
|
|
243
|
+
|
|
244
|
+
# === Reliability Features (Recommended for Production) ===
|
|
245
|
+
|
|
246
|
+
# Transactional Outbox: Ensures events are never lost
|
|
149
247
|
config.use_outbox = true
|
|
150
|
-
config.use_inbox = true
|
|
151
|
-
config.use_dlq = true
|
|
152
248
|
|
|
153
|
-
#
|
|
154
|
-
config.
|
|
155
|
-
|
|
249
|
+
# Idempotent Inbox: Prevents duplicate processing
|
|
250
|
+
config.use_inbox = true
|
|
251
|
+
|
|
252
|
+
# Dead Letter Queue: Isolates poison messages
|
|
253
|
+
config.use_dlq = true
|
|
254
|
+
|
|
255
|
+
# === Consumer Tuning ===
|
|
256
|
+
|
|
257
|
+
# Maximum delivery attempts before moving to DLQ
|
|
258
|
+
config.max_deliver = 5
|
|
259
|
+
|
|
260
|
+
# Time to wait for acknowledgment before redelivery
|
|
261
|
+
config.ack_wait = "30s"
|
|
262
|
+
|
|
263
|
+
# Backoff delays between retries (exponential backoff)
|
|
264
|
+
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
265
|
+
|
|
266
|
+
# === Advanced Options ===
|
|
156
267
|
|
|
157
|
-
#
|
|
268
|
+
# Custom ActiveRecord models (if you have your own tables)
|
|
269
|
+
# config.outbox_model = "CustomOutboxEvent"
|
|
270
|
+
# config.inbox_model = "CustomInboxEvent"
|
|
271
|
+
|
|
272
|
+
# Custom logger
|
|
158
273
|
# config.logger = Rails.logger
|
|
159
274
|
end
|
|
160
275
|
```
|
|
161
276
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
277
|
+
### Configuration Reference
|
|
278
|
+
|
|
279
|
+
| Setting | Type | Default | Description |
|
|
280
|
+
|---------|------|---------|-------------|
|
|
281
|
+
| `nats_urls` | String | `"nats://localhost:4222"` | NATS server URL(s), comma-separated |
|
|
282
|
+
| `env` | String | `"development"` | Environment namespace for streams |
|
|
283
|
+
| `app_name` | String | (required) | Your application identifier |
|
|
284
|
+
| `destination_app` | String | (required) | Target application for events |
|
|
285
|
+
| `use_outbox` | Boolean | `false` | Enable transactional outbox pattern |
|
|
286
|
+
| `use_inbox` | Boolean | `false` | Enable idempotent inbox pattern |
|
|
287
|
+
| `use_dlq` | Boolean | `false` | Enable dead letter queue |
|
|
288
|
+
| `max_deliver` | Integer | `5` | Max delivery attempts before DLQ |
|
|
289
|
+
| `ack_wait` | String/Integer | `"30s"` | Acknowledgment timeout |
|
|
290
|
+
| `backoff` | Array | `["1s", "5s", "15s"]` | Retry backoff schedule |
|
|
291
|
+
|
|
292
|
+
### Understanding Configuration Options
|
|
293
|
+
|
|
294
|
+
#### When to Use Outbox
|
|
295
|
+
|
|
296
|
+
Enable `use_outbox` when you need:
|
|
297
|
+
|
|
298
|
+
* **Transactional guarantees**: Publish events as part of database transactions
|
|
299
|
+
* **Reliability**: Ensure events are never lost, even if NATS is temporarily down
|
|
300
|
+
* **Audit trail**: Keep a permanent record of all published events
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# Example: Publishing with outbox ensures atomicity
|
|
304
|
+
ActiveRecord::Base.transaction do
|
|
305
|
+
user.save!
|
|
306
|
+
JetstreamBridge.publish(
|
|
307
|
+
resource_type: "user",
|
|
308
|
+
event_type: "created",
|
|
309
|
+
payload: { id: user.id }
|
|
310
|
+
) # Event is saved in outbox table first
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
#### When to Use Inbox
|
|
315
|
+
|
|
316
|
+
Enable `use_inbox` when you need:
|
|
317
|
+
|
|
318
|
+
* **Idempotency**: Process each message exactly once, even with redeliveries
|
|
319
|
+
* **Duplicate protection**: Prevent duplicate processing across restarts
|
|
320
|
+
* **Processing history**: Track which messages have been processed
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# Example: Inbox prevents duplicate processing
|
|
324
|
+
# Even if the message is redelivered, it will be skipped
|
|
325
|
+
# if already marked as processed
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
#### When to Use DLQ
|
|
329
|
+
|
|
330
|
+
Enable `use_dlq` when you need:
|
|
331
|
+
|
|
332
|
+
* **Poison message handling**: Isolate messages that repeatedly fail
|
|
333
|
+
* **Manual intervention**: Review and retry failed messages later
|
|
334
|
+
* **Pipeline protection**: Prevent one bad message from blocking the queue
|
|
335
|
+
|
|
336
|
+
### Logging Configuration
|
|
337
|
+
|
|
338
|
+
JetstreamBridge integrates with your application's logger:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# Use Rails logger (default)
|
|
342
|
+
config.logger = Rails.logger
|
|
343
|
+
|
|
344
|
+
# Use custom logger
|
|
345
|
+
config.logger = Logger.new(STDOUT)
|
|
346
|
+
|
|
347
|
+
# Disable logging
|
|
348
|
+
config.logger = Logger.new(IO::NULL)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Environment Variables
|
|
352
|
+
|
|
353
|
+
Recommended environment variable setup:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
# .env (or your deployment configuration)
|
|
357
|
+
NATS_URLS=nats://nats1:4222,nats://nats2:4222,nats://nats3:4222
|
|
358
|
+
RAILS_ENV=production
|
|
359
|
+
APP_NAME=api_service
|
|
360
|
+
DESTINATION_APP=notification_service
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Generated Streams and Subjects
|
|
166
364
|
|
|
167
|
-
|
|
365
|
+
Based on your configuration, JetStream Bridge automatically creates:
|
|
168
366
|
|
|
169
|
-
|
|
367
|
+
* **Stream Name**: `{env}-jetstream-bridge-stream`
|
|
368
|
+
* Example: `production-jetstream-bridge-stream`
|
|
369
|
+
|
|
370
|
+
* **Publish Subject**: `{env}.{app_name}.sync.{destination_app}`
|
|
371
|
+
* Example: `production.api_service.sync.notification_service`
|
|
372
|
+
|
|
373
|
+
* **Subscribe Subject**: `{env}.{destination_app}.sync.{app_name}`
|
|
374
|
+
* Example: `production.notification_service.sync.api_service`
|
|
375
|
+
|
|
376
|
+
* **DLQ Subject**: `{env}.{app}.sync.dlq` (per-app DLQ for isolation)
|
|
377
|
+
* Example: `production.api_service.sync.dlq`
|
|
170
378
|
|
|
171
379
|
---
|
|
172
380
|
|
|
173
381
|
## 📡 Subject Conventions
|
|
174
382
|
|
|
175
|
-
| Direction | Subject Pattern
|
|
176
|
-
|
|
177
|
-
| **Publish** | `{env}.{app}.sync.{dest}`
|
|
178
|
-
| **Subscribe** | `{env}.{dest}.sync.{app}`
|
|
179
|
-
| **DLQ** | `{env}.sync.dlq`
|
|
383
|
+
| Direction | Subject Pattern |
|
|
384
|
+
|---------------|------------------------------|
|
|
385
|
+
| **Publish** | `{env}.{app}.sync.{dest}` |
|
|
386
|
+
| **Subscribe** | `{env}.{dest}.sync.{app}` |
|
|
387
|
+
| **DLQ** | `{env}.{app}.sync.dlq` |
|
|
180
388
|
|
|
181
389
|
* `{app}`: `app_name`
|
|
182
390
|
* `{dest}`: `destination_app`
|
|
183
391
|
* `{env}`: `env`
|
|
184
392
|
|
|
393
|
+
**Note**: Each application has its own DLQ (`{env}.{app}.sync.dlq`) for better isolation, monitoring, and debugging. This allows you to track failed messages per service.
|
|
394
|
+
|
|
185
395
|
---
|
|
186
396
|
|
|
187
397
|
## 🧱 Stream Topology (auto-ensure and overlap-safe)
|
|
@@ -249,51 +459,649 @@ add_index :jetstream_inbox_events, :status
|
|
|
249
459
|
|
|
250
460
|
## 📤 Publish Events
|
|
251
461
|
|
|
462
|
+
JetStream Bridge provides two ways to publish events: a convenience method and a direct publisher instance.
|
|
463
|
+
|
|
464
|
+
### Using the Convenience Method to Consume Events
|
|
465
|
+
|
|
466
|
+
The simplest way to publish events:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
# Basic usage with structured parameters
|
|
470
|
+
JetstreamBridge.publish(
|
|
471
|
+
resource_type: "user",
|
|
472
|
+
event_type: "created",
|
|
473
|
+
payload: { id: user.id, email: user.email, name: user.name }
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Returns true on success, raises error on failure
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Publishing Patterns
|
|
480
|
+
|
|
481
|
+
#### 1. Structured Parameters (Recommended)
|
|
482
|
+
|
|
483
|
+
Best for explicit, clear code:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
JetstreamBridge.publish(
|
|
487
|
+
resource_type: "user",
|
|
488
|
+
event_type: "created",
|
|
489
|
+
payload: { id: "01H...", email: "ada@example.com" },
|
|
490
|
+
# Optional parameters:
|
|
491
|
+
event_id: SecureRandom.uuid, # Auto-generated if not provided
|
|
492
|
+
trace_id: request_id, # For distributed tracing
|
|
493
|
+
occurred_at: Time.now.utc, # Defaults to current time
|
|
494
|
+
subject: "custom.subject.override" # Override default subject
|
|
495
|
+
)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
#### 2. Simplified Hash (Infers resource_type)
|
|
499
|
+
|
|
500
|
+
Use dot notation in `event_type` to infer `resource_type`:
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
JetstreamBridge.publish(
|
|
504
|
+
event_type: "user.created", # "user" becomes resource_type
|
|
505
|
+
payload: { id: "01H...", email: "ada@example.com" }
|
|
506
|
+
)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
#### 3. Complete Envelope (Advanced)
|
|
510
|
+
|
|
511
|
+
Pass a full envelope hash for maximum control:
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
JetstreamBridge.publish(
|
|
515
|
+
event_type: "created",
|
|
516
|
+
resource_type: "user",
|
|
517
|
+
payload: { id: "01H...", email: "ada@example.com" },
|
|
518
|
+
event_id: "custom-event-id",
|
|
519
|
+
occurred_at: 1.hour.ago.iso8601,
|
|
520
|
+
producer: "custom-producer"
|
|
521
|
+
)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Using Publisher Instances
|
|
525
|
+
|
|
526
|
+
For more control or batch operations:
|
|
527
|
+
|
|
252
528
|
```ruby
|
|
253
529
|
publisher = JetstreamBridge::Publisher.new
|
|
530
|
+
|
|
531
|
+
# Publish multiple events
|
|
254
532
|
publisher.publish(
|
|
255
533
|
resource_type: "user",
|
|
256
|
-
event_type:
|
|
257
|
-
payload:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
534
|
+
event_type: "created",
|
|
535
|
+
payload: { id: user.id }
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
publisher.publish(
|
|
539
|
+
resource_type: "user",
|
|
540
|
+
event_type: "updated",
|
|
541
|
+
payload: { id: user.id, email: user.email }
|
|
262
542
|
)
|
|
263
543
|
```
|
|
264
544
|
|
|
265
|
-
|
|
545
|
+
#### Thread Safety
|
|
546
|
+
|
|
547
|
+
Publisher instances are **thread-safe** and can be shared across multiple threads:
|
|
266
548
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
549
|
+
```ruby
|
|
550
|
+
# Safe: Share publisher across threads
|
|
551
|
+
@publisher = JetstreamBridge::Publisher.new
|
|
552
|
+
|
|
553
|
+
# Multiple threads can publish concurrently
|
|
554
|
+
threads = 10.times.map do |i|
|
|
555
|
+
Thread.new do
|
|
556
|
+
@publisher.publish(
|
|
557
|
+
event_type: "user.created",
|
|
558
|
+
payload: { id: i, name: "User #{i}" }
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
threads.each(&:join)
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
The underlying NATS connection is managed through a thread-safe singleton, ensuring safe concurrent access. However, for high-throughput scenarios, consider:
|
|
567
|
+
|
|
568
|
+
* Using the convenience method `JetstreamBridge.publish(...)` which handles connection management automatically
|
|
569
|
+
* Creating a connection pool if you need isolated error handling per thread
|
|
570
|
+
* Using batch publishing for bulk operations: `JetstreamBridge.publish_batch { ... }`
|
|
571
|
+
|
|
572
|
+
### Publishing with Transactions (Outbox Pattern)
|
|
573
|
+
|
|
574
|
+
When `use_outbox` is enabled, events are saved to the database first:
|
|
575
|
+
|
|
576
|
+
```ruby
|
|
577
|
+
# Atomic: both user creation and event publishing succeed or fail together
|
|
578
|
+
ActiveRecord::Base.transaction do
|
|
579
|
+
user = User.create!(email: "ada@example.com")
|
|
580
|
+
|
|
581
|
+
JetstreamBridge.publish(
|
|
582
|
+
resource_type: "user",
|
|
583
|
+
event_type: "created",
|
|
584
|
+
payload: { id: user.id, email: user.email }
|
|
585
|
+
)
|
|
586
|
+
end
|
|
587
|
+
# Event is saved in outbox table, published to NATS asynchronously
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Outbox Behavior
|
|
591
|
+
|
|
592
|
+
If **Outbox** is enabled (`config.use_outbox = true`):
|
|
593
|
+
|
|
594
|
+
* ✅ Events are saved to `jetstream_outbox_events` table first
|
|
595
|
+
* ✅ Published with `nats-msg-id` header for idempotency
|
|
596
|
+
* ✅ Marked as `sent` on success or `failed` with error details
|
|
597
|
+
* ✅ Survives NATS downtime - events queued for retry
|
|
598
|
+
* ✅ Provides audit trail of all published events
|
|
599
|
+
|
|
600
|
+
### Real-World Publishing Examples
|
|
601
|
+
|
|
602
|
+
#### Publishing Domain Events
|
|
603
|
+
|
|
604
|
+
```ruby
|
|
605
|
+
# After user registration
|
|
606
|
+
class UsersController < ApplicationController
|
|
607
|
+
def create
|
|
608
|
+
ActiveRecord::Base.transaction do
|
|
609
|
+
@user = User.create!(user_params)
|
|
610
|
+
|
|
611
|
+
JetstreamBridge.publish(
|
|
612
|
+
resource_type: "user",
|
|
613
|
+
event_type: "registered",
|
|
614
|
+
payload: {
|
|
615
|
+
id: @user.id,
|
|
616
|
+
email: @user.email,
|
|
617
|
+
name: @user.name,
|
|
618
|
+
plan: @user.plan
|
|
619
|
+
},
|
|
620
|
+
trace_id: request.request_id
|
|
621
|
+
)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
redirect_to @user
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
#### Publishing State Changes
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
# In your model or service object
|
|
633
|
+
class Order
|
|
634
|
+
after_commit :publish_status_change, if: :saved_change_to_status?
|
|
635
|
+
|
|
636
|
+
private
|
|
637
|
+
|
|
638
|
+
def publish_status_change
|
|
639
|
+
JetstreamBridge.publish(
|
|
640
|
+
resource_type: "order",
|
|
641
|
+
event_type: "status_changed",
|
|
642
|
+
payload: {
|
|
643
|
+
id: id,
|
|
644
|
+
status: status,
|
|
645
|
+
previous_status: status_before_last_save,
|
|
646
|
+
changed_at: updated_at
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
#### Publishing with Distributed Tracing
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
# Include trace_id for correlation across services
|
|
657
|
+
JetstreamBridge.publish(
|
|
658
|
+
resource_type: "payment",
|
|
659
|
+
event_type: "processed",
|
|
660
|
+
payload: { order_id: order.id, amount: payment.amount },
|
|
661
|
+
trace_id: Current.request_id,
|
|
662
|
+
event_id: payment.transaction_id
|
|
663
|
+
)
|
|
664
|
+
```
|
|
270
665
|
|
|
271
666
|
---
|
|
272
667
|
|
|
273
668
|
## 📥 Consume Events
|
|
274
669
|
|
|
670
|
+
JetStream Bridge provides two ways to consume events: a convenience method and direct consumer instances.
|
|
671
|
+
|
|
672
|
+
### Using the Convenience Method (Recommended)
|
|
673
|
+
|
|
674
|
+
The simplest way to start consuming events:
|
|
675
|
+
|
|
275
676
|
```ruby
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
#
|
|
279
|
-
|
|
280
|
-
|
|
677
|
+
# Start consumer and run in current thread (receives Event object)
|
|
678
|
+
consumer = JetstreamBridge.subscribe do |event|
|
|
679
|
+
# event is a Models::Event object with structured access
|
|
680
|
+
puts "Processing: #{event.type}"
|
|
681
|
+
puts "Payload: #{event.payload.to_h}"
|
|
682
|
+
puts "Event ID: #{event.event_id}"
|
|
683
|
+
puts "Deliveries: #{event.deliveries}"
|
|
684
|
+
|
|
685
|
+
User.find_or_create_by(id: event.payload.id) do |user|
|
|
686
|
+
user.email = event.payload.email
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
consumer.run! # Blocks and processes messages
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
**Note:** The consumer handler receives a structured `Models::Event` object (not a raw hash) with convenient accessor methods for event data and metadata.
|
|
694
|
+
|
|
695
|
+
### Understanding the Event Object
|
|
696
|
+
|
|
697
|
+
Consumers receive a `JetstreamBridge::Models::Event` object that provides structured access to event data and metadata:
|
|
698
|
+
|
|
699
|
+
```ruby
|
|
700
|
+
JetstreamBridge.subscribe do |event|
|
|
701
|
+
# Event data
|
|
702
|
+
event.event_id # => "abc-123-def"
|
|
703
|
+
event.type # => "user.created" (event_type)
|
|
704
|
+
event.resource_type # => "user"
|
|
705
|
+
event.resource_id # => "123"
|
|
706
|
+
event.producer # => "api_service"
|
|
707
|
+
event.occurred_at # => 2025-01-15 10:30:00 UTC (Time object)
|
|
708
|
+
event.trace_id # => "xyz789"
|
|
709
|
+
|
|
710
|
+
# Payload access (method-style or hash-style)
|
|
711
|
+
event.payload.id # => 123
|
|
712
|
+
event.payload.email # => "ada@example.com"
|
|
713
|
+
event.payload["id"] # => 123 (also works)
|
|
714
|
+
event.payload.to_h # => { "id" => 123, "email" => "ada@example.com" }
|
|
715
|
+
|
|
716
|
+
# Delivery metadata
|
|
717
|
+
event.deliveries # => 1 (delivery attempt count)
|
|
718
|
+
event.subject # => "production.api.sync.worker"
|
|
719
|
+
event.stream # => "production-jetstream-bridge-stream"
|
|
720
|
+
event.sequence # => 42 (stream sequence number)
|
|
721
|
+
|
|
722
|
+
# Access all metadata
|
|
723
|
+
event.metadata.to_h # => { subject: "...", deliveries: 1, ... }
|
|
724
|
+
|
|
725
|
+
# Convert to hash (for backwards compatibility)
|
|
726
|
+
event.to_h # => Full event as hash
|
|
727
|
+
event["event_type"] # => "user.created" (hash-style access)
|
|
728
|
+
end
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
This structured approach provides type safety, cleaner code, and better IDE support compared to raw hashes.
|
|
732
|
+
|
|
733
|
+
### Consuming Patterns
|
|
734
|
+
|
|
735
|
+
#### 1. Basic Consumer with Block
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
consumer = JetstreamBridge.subscribe do |event|
|
|
739
|
+
# event: Models::Event object with structured access
|
|
740
|
+
# event.type: Event type (e.g., "created", "user.created")
|
|
741
|
+
# event.payload: PayloadAccessor for event data
|
|
742
|
+
# event.deliveries: Number of delivery attempts (starts at 1)
|
|
743
|
+
# event.metadata: Delivery metadata (subject, stream, sequence, etc.)
|
|
744
|
+
|
|
745
|
+
case event.type
|
|
746
|
+
when "created"
|
|
747
|
+
UserCreatedHandler.call(event.payload.to_h)
|
|
748
|
+
when "updated"
|
|
749
|
+
UserUpdatedHandler.call(event.payload.to_h)
|
|
750
|
+
when "deleted"
|
|
751
|
+
UserDeletedHandler.call(event.payload.to_h)
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
consumer.run! # Start consuming
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
#### 2. Run Consumer in Background Thread
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
# Returns a Thread instead of Consumer
|
|
762
|
+
thread = JetstreamBridge.subscribe(run: true) do |event|
|
|
763
|
+
ProcessEventJob.perform_later(event.to_h)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Consumer runs in background
|
|
767
|
+
# Your application continues
|
|
768
|
+
|
|
769
|
+
# Later, to stop:
|
|
770
|
+
thread.kill
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
#### 3. Consumer with Handler Object
|
|
774
|
+
|
|
775
|
+
```ruby
|
|
776
|
+
class EventHandler
|
|
777
|
+
def call(event)
|
|
778
|
+
logger.info "Processing #{event.type} from #{event.subject} (attempt #{event.deliveries})"
|
|
779
|
+
# Your logic here
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
handler = EventHandler.new
|
|
784
|
+
consumer = JetstreamBridge.subscribe(handler)
|
|
785
|
+
consumer.run!
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
#### 4. Consumer with Custom Configuration
|
|
789
|
+
|
|
790
|
+
```ruby
|
|
791
|
+
consumer = JetstreamBridge.subscribe(
|
|
792
|
+
durable_name: "my-custom-consumer",
|
|
793
|
+
batch_size: 10
|
|
794
|
+
) do |event|
|
|
795
|
+
# Process events in batches of 10
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
consumer.run!
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
#### 5. Consumer with Middleware
|
|
802
|
+
|
|
803
|
+
Add cross-cutting concerns like logging, metrics, and tracing using middleware:
|
|
804
|
+
|
|
805
|
+
```ruby
|
|
806
|
+
consumer = JetstreamBridge.subscribe do |event|
|
|
807
|
+
process_event(event)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Add built-in middleware
|
|
811
|
+
consumer.use(JetstreamBridge::Consumer::LoggingMiddleware.new)
|
|
812
|
+
consumer.use(JetstreamBridge::Consumer::MetricsMiddleware.new(
|
|
813
|
+
on_success: ->(event, duration) { StatsD.timing("event.process", duration) },
|
|
814
|
+
on_failure: ->(event, error) { StatsD.increment("event.failed") }
|
|
815
|
+
))
|
|
816
|
+
consumer.use(JetstreamBridge::Consumer::TimeoutMiddleware.new(timeout: 30))
|
|
817
|
+
|
|
818
|
+
consumer.run!
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
**Available Middleware:**
|
|
822
|
+
|
|
823
|
+
* `LoggingMiddleware` - Logs event processing start, completion, and errors
|
|
824
|
+
* `ErrorHandlingMiddleware` - Custom error handling with callbacks
|
|
825
|
+
* `MetricsMiddleware` - Track processing metrics and timing
|
|
826
|
+
* `TracingMiddleware` - Distributed tracing support (sets Current.trace_id)
|
|
827
|
+
* `TimeoutMiddleware` - Prevent long-running handlers from blocking
|
|
828
|
+
|
|
829
|
+
**Custom Middleware Example:**
|
|
830
|
+
|
|
831
|
+
```ruby
|
|
832
|
+
class RetryMiddleware
|
|
833
|
+
def initialize(max_retries: 3)
|
|
834
|
+
@max_retries = max_retries
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def call(event)
|
|
838
|
+
retries = 0
|
|
839
|
+
begin
|
|
840
|
+
yield
|
|
841
|
+
rescue TransientError => e
|
|
842
|
+
retries += 1
|
|
843
|
+
if retries < @max_retries
|
|
844
|
+
sleep(retries)
|
|
845
|
+
retry
|
|
846
|
+
else
|
|
847
|
+
raise
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
consumer.use(RetryMiddleware.new(max_retries: 5))
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Using Consumer Instances Directly
|
|
857
|
+
|
|
858
|
+
For more control over the consumer lifecycle:
|
|
859
|
+
|
|
860
|
+
```ruby
|
|
861
|
+
consumer = JetstreamBridge::Consumer.new do |event|
|
|
862
|
+
# event: Models::Event object with structured access
|
|
863
|
+
|
|
864
|
+
logger.info "Processing #{event.type} (attempt #{event.deliveries})"
|
|
865
|
+
|
|
866
|
+
begin
|
|
867
|
+
process_event(event)
|
|
868
|
+
rescue RecoverableError => e
|
|
869
|
+
# Let JetStream retry with backoff
|
|
870
|
+
raise
|
|
871
|
+
rescue UnrecoverableError => e
|
|
872
|
+
# Log and acknowledge to prevent infinite retries
|
|
873
|
+
logger.error "Unrecoverable error: #{e.message}"
|
|
874
|
+
# Automatically moved to DLQ if configured
|
|
875
|
+
end
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
consumer.run! # Start processing
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### Understanding the Handler Signature
|
|
882
|
+
|
|
883
|
+
Your handler receives a single `Models::Event` parameter with all event and metadata:
|
|
884
|
+
|
|
885
|
+
```ruby
|
|
886
|
+
JetstreamBridge.subscribe do |event|
|
|
887
|
+
# event: Models::Event - structured event object
|
|
888
|
+
|
|
889
|
+
puts "Event type: #{event.type}"
|
|
890
|
+
puts "Event ID: #{event.event_id}"
|
|
891
|
+
puts "Subject: #{event.subject}"
|
|
892
|
+
puts "Delivery attempt: #{event.deliveries}"
|
|
893
|
+
puts "Trace ID: #{event.trace_id}"
|
|
894
|
+
|
|
895
|
+
# Access payload
|
|
896
|
+
puts "User ID: #{event.payload.id}"
|
|
897
|
+
puts "Email: #{event.payload.email}"
|
|
898
|
+
|
|
899
|
+
# Implement retry logic based on deliveries if needed
|
|
900
|
+
raise "Transient error, retry" if event.deliveries < 3 && some_transient_condition?
|
|
901
|
+
end
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
### Consumer Options
|
|
905
|
+
|
|
906
|
+
| Option | Type | Default | Description |
|
|
907
|
+
|--------|------|---------|-------------|
|
|
908
|
+
| `handler` | Proc/Callable | (required) | Block or object that responds to `call` |
|
|
909
|
+
| `run` | Boolean | `false` | Start consumer in background thread |
|
|
910
|
+
| `durable_name` | String | From config | Custom durable consumer name |
|
|
911
|
+
| `batch_size` | Integer | From config | Number of messages to fetch at once |
|
|
912
|
+
|
|
913
|
+
### Inbox Behavior
|
|
914
|
+
|
|
915
|
+
If **Inbox** is enabled (`config.use_inbox = true`):
|
|
916
|
+
|
|
917
|
+
* ✅ Deduplicates by `event_id` (or stream sequence as fallback)
|
|
918
|
+
* ✅ Records processing state, errors, and timestamps
|
|
919
|
+
* ✅ Skips already-processed messages automatically
|
|
920
|
+
* ✅ Provides processing history and audit trail
|
|
921
|
+
* ✅ Safe across restarts and redeliveries
|
|
922
|
+
|
|
923
|
+
```ruby
|
|
924
|
+
# With inbox enabled, this is automatically idempotent:
|
|
925
|
+
JetstreamBridge.subscribe do |event|
|
|
926
|
+
# Even if message is redelivered, it won't execute twice
|
|
927
|
+
User.create!(email: event.payload.email)
|
|
928
|
+
end
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### Real-World Consuming Examples
|
|
932
|
+
|
|
933
|
+
#### Processing in Rake Task
|
|
934
|
+
|
|
935
|
+
```ruby
|
|
936
|
+
# lib/tasks/consume_events.rake
|
|
937
|
+
namespace :jetstream do
|
|
938
|
+
desc "Start event consumer"
|
|
939
|
+
task consume: :environment do
|
|
940
|
+
consumer = JetstreamBridge.subscribe do |event|
|
|
941
|
+
Rails.logger.info "Processing #{event.type} (attempt #{event.deliveries})"
|
|
942
|
+
|
|
943
|
+
case event.resource_type
|
|
944
|
+
when "user"
|
|
945
|
+
UserEventHandler.process(event)
|
|
946
|
+
when "order"
|
|
947
|
+
OrderEventHandler.process(event)
|
|
948
|
+
else
|
|
949
|
+
Rails.logger.warn "Unknown resource type: #{event.resource_type}"
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Graceful shutdown on SIGTERM
|
|
954
|
+
trap("TERM") { consumer.stop! }
|
|
955
|
+
|
|
956
|
+
consumer.run!
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
#### Background Job Processing
|
|
962
|
+
|
|
963
|
+
```ruby
|
|
964
|
+
# Offload to background jobs for complex processing
|
|
965
|
+
JetstreamBridge.subscribe(run: true) do |event|
|
|
966
|
+
ProcessEventJob.perform_later(
|
|
967
|
+
event: event.to_h.to_json,
|
|
968
|
+
event_id: event.event_id,
|
|
969
|
+
trace_id: event.trace_id
|
|
970
|
+
)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# app/jobs/process_event_job.rb
|
|
974
|
+
class ProcessEventJob < ApplicationJob
|
|
975
|
+
queue_as :events
|
|
976
|
+
|
|
977
|
+
def perform(event:, event_id:, trace_id:)
|
|
978
|
+
event_data = JSON.parse(event)
|
|
979
|
+
|
|
980
|
+
# Complex processing with retries
|
|
981
|
+
case event_data["type"]
|
|
982
|
+
when "user.created"
|
|
983
|
+
SendWelcomeEmailService.call(event_data["payload"])
|
|
984
|
+
when "order.completed"
|
|
985
|
+
GenerateInvoiceService.call(event_data["payload"])
|
|
986
|
+
end
|
|
987
|
+
rescue => e
|
|
988
|
+
logger.error "Failed to process event #{event_id}: #{e.message}"
|
|
989
|
+
raise # Let Sidekiq retry
|
|
990
|
+
end
|
|
991
|
+
end
|
|
281
992
|
```
|
|
282
993
|
|
|
283
|
-
|
|
284
|
-
overridden if needed:
|
|
994
|
+
#### Event Router Pattern
|
|
285
995
|
|
|
286
996
|
```ruby
|
|
287
|
-
|
|
288
|
-
|
|
997
|
+
# app/services/event_router.rb
|
|
998
|
+
class EventRouter
|
|
999
|
+
def self.route(event)
|
|
1000
|
+
handler_class = "#{event.resource_type.camelize}#{event.type.camelize}Handler"
|
|
1001
|
+
|
|
1002
|
+
if Object.const_defined?(handler_class)
|
|
1003
|
+
handler_class.constantize.new.call(event)
|
|
1004
|
+
else
|
|
1005
|
+
Rails.logger.warn "No handler for #{event.resource_type}.#{event.type}"
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Start consumer with router
|
|
1011
|
+
JetstreamBridge.subscribe do |event|
|
|
1012
|
+
EventRouter.route(event)
|
|
289
1013
|
end.run!
|
|
290
1014
|
```
|
|
291
1015
|
|
|
292
|
-
|
|
1016
|
+
#### Multi-Service Consumer
|
|
1017
|
+
|
|
1018
|
+
```ruby
|
|
1019
|
+
# app/consumers/application_consumer.rb
|
|
1020
|
+
class ApplicationConsumer
|
|
1021
|
+
def self.start!
|
|
1022
|
+
consumer = JetstreamBridge.subscribe do |event|
|
|
1023
|
+
begin
|
|
1024
|
+
new.process(event)
|
|
1025
|
+
rescue => e
|
|
1026
|
+
Rails.logger.error "Error processing event: #{e.message}"
|
|
1027
|
+
raise # Trigger retry/DLQ
|
|
1028
|
+
end
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Handle signals gracefully
|
|
1032
|
+
%w[INT TERM].each do |signal|
|
|
1033
|
+
trap(signal) do
|
|
1034
|
+
Rails.logger.info "Shutting down consumer..."
|
|
1035
|
+
consumer.stop!
|
|
1036
|
+
exit
|
|
1037
|
+
end
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
Rails.logger.info "Consumer started. Press Ctrl+C to stop."
|
|
1041
|
+
consumer.run!
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def process(event)
|
|
1045
|
+
# Log for observability
|
|
1046
|
+
Rails.logger.info(
|
|
1047
|
+
message: "Processing event",
|
|
1048
|
+
event_id: event.event_id,
|
|
1049
|
+
event_type: event.type,
|
|
1050
|
+
resource_type: event.resource_type,
|
|
1051
|
+
trace_id: event.trace_id,
|
|
1052
|
+
subject: event.subject,
|
|
1053
|
+
deliveries: event.deliveries
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Route to specific handler
|
|
1057
|
+
handler = handler_for(event)
|
|
1058
|
+
handler.call(event.payload.to_h, event)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
private
|
|
1062
|
+
|
|
1063
|
+
def handler_for(event)
|
|
1064
|
+
case [event.resource_type, event.type]
|
|
1065
|
+
when ["user", "created"]
|
|
1066
|
+
UserCreatedHandler
|
|
1067
|
+
when ["user", "updated"]
|
|
1068
|
+
UserUpdatedHandler
|
|
1069
|
+
when ["order", "completed"]
|
|
1070
|
+
OrderCompletedHandler
|
|
1071
|
+
else
|
|
1072
|
+
UnknownEventHandler
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
end
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
#### Dockerized Consumer
|
|
1079
|
+
|
|
1080
|
+
```dockerfile
|
|
1081
|
+
# Dockerfile.consumer
|
|
1082
|
+
FROM ruby:3.2
|
|
293
1083
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1084
|
+
WORKDIR /app
|
|
1085
|
+
COPY . .
|
|
1086
|
+
RUN bundle install
|
|
1087
|
+
|
|
1088
|
+
CMD ["bundle", "exec", "rake", "jetstream:consume"]
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
```yaml
|
|
1092
|
+
# docker-compose.yml
|
|
1093
|
+
services:
|
|
1094
|
+
consumer:
|
|
1095
|
+
build:
|
|
1096
|
+
context: .
|
|
1097
|
+
dockerfile: Dockerfile.consumer
|
|
1098
|
+
environment:
|
|
1099
|
+
- NATS_URLS=nats://nats:4222
|
|
1100
|
+
- RAILS_ENV=production
|
|
1101
|
+
depends_on:
|
|
1102
|
+
- nats
|
|
1103
|
+
restart: unless-stopped
|
|
1104
|
+
```
|
|
297
1105
|
|
|
298
1106
|
---
|
|
299
1107
|
|
|
@@ -319,10 +1127,97 @@ If **Inbox** is enabled, the consumer:
|
|
|
319
1127
|
|
|
320
1128
|
## 🧨 Dead-Letter Queue (DLQ)
|
|
321
1129
|
|
|
322
|
-
When enabled, the topology ensures
|
|
323
|
-
**`{env}.sync.dlq`**
|
|
1130
|
+
When enabled, the topology ensures each app has its own DLQ subject:
|
|
1131
|
+
**`{env}.{app_name}.sync.dlq`**
|
|
1132
|
+
|
|
1133
|
+
This per-app DLQ approach provides:
|
|
1134
|
+
|
|
1135
|
+
* **Isolation**: Failed messages from different services don't mix
|
|
1136
|
+
* **Easier Monitoring**: Track DLQ metrics per service
|
|
1137
|
+
* **Simpler Debugging**: Identify which service is having issues
|
|
1138
|
+
* **Independent Processing**: Each team can manage their own DLQ consumer
|
|
1139
|
+
|
|
1140
|
+
### How DLQ Works
|
|
1141
|
+
|
|
1142
|
+
Messages are automatically moved to the DLQ when:
|
|
1143
|
+
|
|
1144
|
+
* Delivery attempts exceed `max_deliver` (default: 5)
|
|
1145
|
+
* Handler raises an unrecoverable error
|
|
1146
|
+
* Message cannot be processed successfully after all retries
|
|
1147
|
+
|
|
1148
|
+
### Consuming DLQ Messages
|
|
1149
|
+
|
|
1150
|
+
You can run a separate consumer to monitor and process DLQ messages:
|
|
1151
|
+
|
|
1152
|
+
```ruby
|
|
1153
|
+
# lib/tasks/dlq_consumer.rake
|
|
1154
|
+
namespace :jetstream do
|
|
1155
|
+
desc "Process Dead Letter Queue messages"
|
|
1156
|
+
task consume_dlq: :environment do
|
|
1157
|
+
# Create a custom consumer for DLQ subject
|
|
1158
|
+
dlq_subject = JetstreamBridge.config.dlq_subject
|
|
1159
|
+
|
|
1160
|
+
consumer = JetstreamBridge::Consumer.new(
|
|
1161
|
+
durable_name: "#{JetstreamBridge.config.env}-dlq-processor"
|
|
1162
|
+
) do |event|
|
|
1163
|
+
# Log failed event for manual review
|
|
1164
|
+
Rails.logger.error(
|
|
1165
|
+
"DLQ Event: #{event.event_id}",
|
|
1166
|
+
event_type: event.type,
|
|
1167
|
+
deliveries: event.deliveries,
|
|
1168
|
+
payload: event.payload.to_h,
|
|
1169
|
+
trace_id: event.trace_id
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# Optionally: store in database for manual intervention
|
|
1173
|
+
FailedEvent.create!(
|
|
1174
|
+
event_id: event.event_id,
|
|
1175
|
+
event_type: event.type,
|
|
1176
|
+
payload: event.payload.to_h,
|
|
1177
|
+
deliveries: event.deliveries,
|
|
1178
|
+
failed_at: Time.current
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
# Or attempt recovery logic
|
|
1182
|
+
case event.type
|
|
1183
|
+
when "payment.processed"
|
|
1184
|
+
# Manual payment reconciliation
|
|
1185
|
+
PaymentReconciliationService.call(event.payload.to_h)
|
|
1186
|
+
else
|
|
1187
|
+
# Alert on-call team
|
|
1188
|
+
AlertService.notify("DLQ event requires attention", event: event.to_h)
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
# Graceful shutdown
|
|
1193
|
+
trap("TERM") { consumer.stop! }
|
|
1194
|
+
|
|
1195
|
+
Rails.logger.info "DLQ Consumer started on #{dlq_subject}"
|
|
1196
|
+
consumer.run!
|
|
1197
|
+
end
|
|
1198
|
+
end
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
**Run the DLQ consumer:**
|
|
1202
|
+
|
|
1203
|
+
```bash
|
|
1204
|
+
bundle exec rake jetstream:consume_dlq
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
### DLQ Monitoring
|
|
1208
|
+
|
|
1209
|
+
Monitor DLQ health and volume:
|
|
1210
|
+
|
|
1211
|
+
```ruby
|
|
1212
|
+
# Check DLQ message count
|
|
1213
|
+
stream_info = JetstreamBridge.stream_info
|
|
1214
|
+
dlq_count = stream_info[:messages] # Messages in DLQ subject
|
|
324
1215
|
|
|
325
|
-
|
|
1216
|
+
# Alert if DLQ is growing
|
|
1217
|
+
if dlq_count > 100
|
|
1218
|
+
AlertService.notify("DLQ has #{dlq_count} messages")
|
|
1219
|
+
end
|
|
1220
|
+
```
|
|
326
1221
|
|
|
327
1222
|
---
|
|
328
1223
|
|
|
@@ -331,8 +1226,9 @@ You may run a separate process to subscribe and triage messages that exceed `max
|
|
|
331
1226
|
### Monitoring
|
|
332
1227
|
|
|
333
1228
|
* **Consumer lag**: `nats consumer info <stream> <durable>`
|
|
334
|
-
* **DLQ volume**:
|
|
335
|
-
*
|
|
1229
|
+
* **DLQ volume**: Monitor your app's DLQ subject `{env}.{app_name}.sync.dlq`
|
|
1230
|
+
* Example: `nats sub "production.api.sync.dlq" --count`
|
|
1231
|
+
* **Outbox backlog**: Alert on `jetstream_outbox_events` with `status != 'sent'` and growing count
|
|
336
1232
|
|
|
337
1233
|
### Scaling
|
|
338
1234
|
|
|
@@ -353,7 +1249,7 @@ health = JetstreamBridge.health_check
|
|
|
353
1249
|
# connected_at: "2025-11-22T20:00:00Z",
|
|
354
1250
|
# stream: { exists: true, name: "...", ... },
|
|
355
1251
|
# config: { env: "production", ... },
|
|
356
|
-
# version: "
|
|
1252
|
+
# version: "4.0.1"
|
|
357
1253
|
# }
|
|
358
1254
|
|
|
359
1255
|
# Force-connect & ensure topology at boot or in a check
|
|
@@ -392,6 +1288,175 @@ JetstreamBridge::DebugHelper.debug_info
|
|
|
392
1288
|
|
|
393
1289
|
---
|
|
394
1290
|
|
|
1291
|
+
## 🤝 Contributing
|
|
1292
|
+
|
|
1293
|
+
We welcome contributions from the community! Here's how you can help:
|
|
1294
|
+
|
|
1295
|
+
### Getting Started
|
|
1296
|
+
|
|
1297
|
+
1. **Fork the repository** on GitHub
|
|
1298
|
+
2. **Clone your fork** locally:
|
|
1299
|
+
|
|
1300
|
+
```bash
|
|
1301
|
+
git clone https://github.com/YOUR_USERNAME/jetstream_bridge.git
|
|
1302
|
+
cd jetstream_bridge
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
3. **Install dependencies**:
|
|
1306
|
+
|
|
1307
|
+
```bash
|
|
1308
|
+
bundle install
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
4. **Set up NATS** for testing (requires Docker):
|
|
1312
|
+
|
|
1313
|
+
```bash
|
|
1314
|
+
docker run -d -p 4222:4222 nats:latest -js
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
### Development Workflow
|
|
1318
|
+
|
|
1319
|
+
1. **Create a feature branch**:
|
|
1320
|
+
|
|
1321
|
+
```bash
|
|
1322
|
+
git checkout -b feature/your-feature-name
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
2. **Make your changes** with tests:
|
|
1326
|
+
* Write meaningful commit messages
|
|
1327
|
+
* Add tests for new functionality
|
|
1328
|
+
* Update documentation as needed
|
|
1329
|
+
|
|
1330
|
+
3. **Run the test suite**:
|
|
1331
|
+
|
|
1332
|
+
```bash
|
|
1333
|
+
bundle exec rspec
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
4. **Check code quality**:
|
|
1337
|
+
|
|
1338
|
+
```bash
|
|
1339
|
+
bundle exec rubocop
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
5. **Push to your fork** and submit a pull request
|
|
1343
|
+
|
|
1344
|
+
### Code Quality Standards
|
|
1345
|
+
|
|
1346
|
+
* **Test Coverage**: Maintain >80% line coverage and >70% branch coverage
|
|
1347
|
+
* **RuboCop**: All code must pass RuboCop checks with zero offenses
|
|
1348
|
+
* **Tests**: All tests must pass before merging
|
|
1349
|
+
* **Documentation**: Update README and inline docs for new features
|
|
1350
|
+
|
|
1351
|
+
### Pull Request Guidelines
|
|
1352
|
+
|
|
1353
|
+
* **Title**: Use clear, descriptive titles (e.g., "Add health check endpoint generator")
|
|
1354
|
+
* **Description**: Explain what changes were made and why
|
|
1355
|
+
* **Tests**: Include tests for bug fixes and new features
|
|
1356
|
+
* **Documentation**: Update relevant documentation
|
|
1357
|
+
* **One feature per PR**: Keep pull requests focused and reviewable
|
|
1358
|
+
|
|
1359
|
+
### Reporting Issues
|
|
1360
|
+
|
|
1361
|
+
When reporting bugs, please include:
|
|
1362
|
+
|
|
1363
|
+
* **Ruby version**: Output of `ruby -v`
|
|
1364
|
+
* **Gem version**: Output of `bundle show jetstream_bridge`
|
|
1365
|
+
* **NATS version**: Version of NATS server
|
|
1366
|
+
* **Steps to reproduce**: Minimal example that reproduces the issue
|
|
1367
|
+
* **Expected behavior**: What you expected to happen
|
|
1368
|
+
* **Actual behavior**: What actually happened
|
|
1369
|
+
* **Logs/errors**: Relevant error messages or stack traces
|
|
1370
|
+
|
|
1371
|
+
### Feature Requests
|
|
1372
|
+
|
|
1373
|
+
We love hearing your ideas! When proposing features:
|
|
1374
|
+
|
|
1375
|
+
* Search existing issues to avoid duplicates
|
|
1376
|
+
* Describe the problem you're trying to solve
|
|
1377
|
+
* Explain your proposed solution
|
|
1378
|
+
* Consider backwards compatibility
|
|
1379
|
+
|
|
1380
|
+
## 🏗️ Development
|
|
1381
|
+
|
|
1382
|
+
### Running Tests
|
|
1383
|
+
|
|
1384
|
+
```bash
|
|
1385
|
+
# Run all tests
|
|
1386
|
+
bundle exec rspec
|
|
1387
|
+
|
|
1388
|
+
# Run specific test file
|
|
1389
|
+
bundle exec rspec spec/publisher/publisher_spec.rb
|
|
1390
|
+
|
|
1391
|
+
# Run with coverage report
|
|
1392
|
+
COVERAGE=true bundle exec rspec
|
|
1393
|
+
```
|
|
1394
|
+
|
|
1395
|
+
### Code Coverage
|
|
1396
|
+
|
|
1397
|
+
Coverage reports are generated automatically and saved to `coverage/`. View the HTML report:
|
|
1398
|
+
|
|
1399
|
+
```bash
|
|
1400
|
+
open coverage/index.html
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
### Linting
|
|
1404
|
+
|
|
1405
|
+
```bash
|
|
1406
|
+
# Run RuboCop
|
|
1407
|
+
bundle exec rubocop
|
|
1408
|
+
|
|
1409
|
+
# Auto-fix violations
|
|
1410
|
+
bundle exec rubocop -A
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
### Local Testing with Rails
|
|
1414
|
+
|
|
1415
|
+
To test the gem in a Rails application:
|
|
1416
|
+
|
|
1417
|
+
1. Point your Gemfile to the local path:
|
|
1418
|
+
|
|
1419
|
+
```ruby
|
|
1420
|
+
gem "jetstream_bridge", path: "../jetstream_bridge"
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
2. Run bundle:
|
|
1424
|
+
|
|
1425
|
+
```bash
|
|
1426
|
+
bundle install
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
## 📋 Code of Conduct
|
|
1430
|
+
|
|
1431
|
+
This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
|
1432
|
+
|
|
1433
|
+
### Our Standards
|
|
1434
|
+
|
|
1435
|
+
* **Be respectful**: Treat everyone with respect and consideration
|
|
1436
|
+
* **Be inclusive**: Welcome diverse perspectives and experiences
|
|
1437
|
+
* **Be collaborative**: Work together constructively
|
|
1438
|
+
* **Be professional**: Keep discussions focused and constructive
|
|
1439
|
+
|
|
1440
|
+
## 🌟 Community
|
|
1441
|
+
|
|
1442
|
+
* **Discussions**: Use GitHub Discussions for questions and ideas
|
|
1443
|
+
* **Issues**: Report bugs and request features via GitHub Issues
|
|
1444
|
+
* **Pull Requests**: Submit improvements via pull requests
|
|
1445
|
+
|
|
1446
|
+
## 🙏 Acknowledgments
|
|
1447
|
+
|
|
1448
|
+
Built with:
|
|
1449
|
+
|
|
1450
|
+
* [NATS.io](https://nats.io) - High-performance messaging system
|
|
1451
|
+
* [nats-pure.rb](https://github.com/nats-io/nats-pure.rb) - Ruby client for NATS
|
|
1452
|
+
|
|
1453
|
+
## 📊 Project Status
|
|
1454
|
+
|
|
1455
|
+
* **CI/CD**: Automated testing and code quality checks
|
|
1456
|
+
* **Code Coverage**: 85%+ maintained
|
|
1457
|
+
* **Active Development**: Regular updates and maintenance
|
|
1458
|
+
* **Semantic Versioning**: Follows [SemVer](https://semver.org/)
|
|
1459
|
+
|
|
395
1460
|
## 📄 License
|
|
396
1461
|
|
|
397
|
-
[MIT License](LICENSE)
|
|
1462
|
+
[MIT License](LICENSE) - Copyright (c) 2025 Mike Attara
|