jetstream_bridge 1.4.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 +7 -0
- data/.github/workflows/release.yml +150 -0
- data/.gitignore +56 -0
- data/.idea/.gitignore +8 -0
- data/.idea/dictionaries/project.xml +14 -0
- data/.idea/jetstream_bridge.iml +97 -0
- data/.idea/misc.xml +4 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +274 -0
- data/LICENSE +21 -0
- data/README.md +217 -0
- data/jetstream_bridge.gemspec +61 -0
- data/lib/jetstream_bridge/config.rb +48 -0
- data/lib/jetstream_bridge/connection.rb +75 -0
- data/lib/jetstream_bridge/consumer.rb +106 -0
- data/lib/jetstream_bridge/consumer_config.rb +32 -0
- data/lib/jetstream_bridge/dlq.rb +24 -0
- data/lib/jetstream_bridge/duration.rb +46 -0
- data/lib/jetstream_bridge/inbox_event.rb +46 -0
- data/lib/jetstream_bridge/logging.rb +59 -0
- data/lib/jetstream_bridge/message_processor.rb +65 -0
- data/lib/jetstream_bridge/outbox_event.rb +60 -0
- data/lib/jetstream_bridge/overlap_guard.rb +65 -0
- data/lib/jetstream_bridge/publisher.rb +90 -0
- data/lib/jetstream_bridge/stream.rb +66 -0
- data/lib/jetstream_bridge/subject_matcher.rb +65 -0
- data/lib/jetstream_bridge/topology.rb +22 -0
- data/lib/jetstream_bridge/version.rb +8 -0
- data/lib/jetstream_bridge.rb +51 -0
- metadata +237 -0
data/README.md
ADDED
@@ -0,0 +1,217 @@
|
|
1
|
+
# Jetstream Bridge
|
2
|
+
|
3
|
+
**Production-safe realtime data bridge** between systems using **NATS JetStream**.
|
4
|
+
Includes durable consumers, backpressure, retries, **DLQ**, and optional **Inbox/Outbox** for end-to-end reliability.
|
5
|
+
|
6
|
+
---
|
7
|
+
|
8
|
+
## โจ Features
|
9
|
+
|
10
|
+
- ๐ Simple **Publisher** and **Consumer** interfaces
|
11
|
+
- ๐ก **Outbox** (reliable send) & **Inbox** (idempotent receive)
|
12
|
+
- ๐งจ **DLQ** for poison messages
|
13
|
+
- โ๏ธ Durable `pull_subscribe` with exponential backoff & `max_deliver`
|
14
|
+
- ๐ฏ Configurable **source** and **destination** applications
|
15
|
+
- ๐ Built-in observability
|
16
|
+
|
17
|
+
---
|
18
|
+
|
19
|
+
## ๐ฆ Install
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
# Gemfile
|
23
|
+
gem "jetstream_bridge"
|
24
|
+
```
|
25
|
+
|
26
|
+
```bash
|
27
|
+
bundle install
|
28
|
+
```
|
29
|
+
|
30
|
+
---
|
31
|
+
|
32
|
+
## ๐ง Configure (Rails)
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
# config/initializers/jetstream_bridge.rb
|
36
|
+
JetstreamBridge.configure do |config|
|
37
|
+
# NATS Connection
|
38
|
+
config.nats_urls = ENV.fetch("NATS_URLS", "nats://localhost:4222")
|
39
|
+
config.env = ENV.fetch("NATS_ENV", "development")
|
40
|
+
config.app_name = ENV.fetch("APP_NAME", "app")
|
41
|
+
config.destination_app = ENV["DESTINATION_APP"]
|
42
|
+
|
43
|
+
# Consumer Tuning
|
44
|
+
config.max_deliver = 5
|
45
|
+
config.ack_wait = "30s"
|
46
|
+
config.backoff = %w[1s 5s 15s 30s 60s]
|
47
|
+
|
48
|
+
# Reliability Features
|
49
|
+
config.use_outbox = true
|
50
|
+
config.use_inbox = true
|
51
|
+
config.use_dlq = true
|
52
|
+
|
53
|
+
# Models (override if custom)
|
54
|
+
config.outbox_model = "JetstreamBridge::OutboxEvent"
|
55
|
+
config.inbox_model = "JetstreamBridge::InboxEvent"
|
56
|
+
end
|
57
|
+
```
|
58
|
+
|
59
|
+
> **Note:**
|
60
|
+
> - `stream_name` defaults to `{env}-stream-bridge`
|
61
|
+
> - `dlq_subject` defaults to `data.sync.dlq`
|
62
|
+
|
63
|
+
---
|
64
|
+
|
65
|
+
## ๐ก Subject Conventions
|
66
|
+
|
67
|
+
| Direction | Subject Pattern |
|
68
|
+
|---------------|--------------------------------|
|
69
|
+
| **Publish** | `{env}.data.sync.{app}.{dest}` |
|
70
|
+
| **Subscribe** | `{env}.data.sync.{dest}.{app}` |
|
71
|
+
| **DLQ** | `{env}.data.sync.dlq` |
|
72
|
+
|
73
|
+
- `{app}`: Your `app_name`
|
74
|
+
- `{dest}`: Your `destination_app`
|
75
|
+
- `{env}`: Your `env``
|
76
|
+
|
77
|
+
---
|
78
|
+
|
79
|
+
## ๐ Database Setup (Inbox/Outbox)
|
80
|
+
|
81
|
+
Run the installer:
|
82
|
+
|
83
|
+
```bash
|
84
|
+
rails jetstream_bridge:install --all
|
85
|
+
```
|
86
|
+
|
87
|
+
This creates:
|
88
|
+
|
89
|
+
1. **Initializer** (`config/initializers/jetstream_bridge.rb`)
|
90
|
+
2. **Migrations** (if enabled):
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
# Outbox
|
94
|
+
create_table :jetstream_outbox_events do |t|
|
95
|
+
t.string :resource_type, null: false
|
96
|
+
t.string :resource_id, null: false
|
97
|
+
t.string :event_type, null: false
|
98
|
+
t.jsonb :payload, null: false, default: {}
|
99
|
+
t.datetime :published_at
|
100
|
+
t.integer :attempts, default: 0
|
101
|
+
t.text :last_error
|
102
|
+
t.timestamps
|
103
|
+
end
|
104
|
+
add_index :jetstream_outbox_events, [:resource_type, :resource_id]
|
105
|
+
|
106
|
+
# Inbox
|
107
|
+
create_table :jetstream_inbox_events do |t|
|
108
|
+
t.string :event_id, null: false
|
109
|
+
t.string :subject, null: false
|
110
|
+
t.datetime :processed_at
|
111
|
+
t.text :error
|
112
|
+
t.timestamps
|
113
|
+
end
|
114
|
+
add_index :jetstream_inbox_events, :event_id, unique: true
|
115
|
+
```
|
116
|
+
|
117
|
+
---
|
118
|
+
|
119
|
+
## ๐ค Publish Events
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
publisher = JetstreamBridge::Publisher.new
|
123
|
+
publisher.publish(
|
124
|
+
resource_type: "user",
|
125
|
+
resource_id: "01H1234567890ABCDEF",
|
126
|
+
event_type: "created",
|
127
|
+
payload: { id: "01H...", name: "Ada" }
|
128
|
+
)
|
129
|
+
```
|
130
|
+
|
131
|
+
> **Ephemeral Mode** (for short-lived scripts):
|
132
|
+
> ```ruby
|
133
|
+
> JetstreamBridge::Publisher.new(persistent: false).publish(...)
|
134
|
+
> ```
|
135
|
+
|
136
|
+
---
|
137
|
+
|
138
|
+
## ๐ Outbox (If Enabled)
|
139
|
+
|
140
|
+
Events are written to the Outbox table. Flush periodically:
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
# app/jobs/outbox_flush_job.rb
|
144
|
+
class OutboxFlushJob < ApplicationJob
|
145
|
+
def perform
|
146
|
+
JetstreamBridge::Publisher.new.flush_outbox
|
147
|
+
end
|
148
|
+
end
|
149
|
+
```
|
150
|
+
|
151
|
+
Schedule this job to run every minute.
|
152
|
+
|
153
|
+
---
|
154
|
+
|
155
|
+
## ๐ฅ Consume Events
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
JetstreamBridge::Consumer.new(
|
159
|
+
durable_name: "#{Rails.env}-peerapp-events",
|
160
|
+
batch_size: 25
|
161
|
+
) do |event, subject, deliveries|
|
162
|
+
# Your idempotent domain logic here
|
163
|
+
UserCreatedHandler.call(event.payload)
|
164
|
+
end.run!
|
165
|
+
```
|
166
|
+
|
167
|
+
---
|
168
|
+
|
169
|
+
## ๐ฌ Envelope Format
|
170
|
+
|
171
|
+
Published events include:
|
172
|
+
|
173
|
+
```json
|
174
|
+
{
|
175
|
+
"event_id": "01H1234567890ABCDEF",
|
176
|
+
"schema_version": 1,
|
177
|
+
"producer": "myapp",
|
178
|
+
"resource_type": "user",
|
179
|
+
"resource_id": "01H1234567890ABCDEF",
|
180
|
+
"event_type": "created",
|
181
|
+
"occurred_at": "2025-08-13T21:00:00Z",
|
182
|
+
"trace_id": "abc123",
|
183
|
+
"payload": { "id": "01H...", "name": "Ada" }
|
184
|
+
}
|
185
|
+
```
|
186
|
+
|
187
|
+
---
|
188
|
+
|
189
|
+
## ๐ Operations Guide
|
190
|
+
|
191
|
+
### Monitoring
|
192
|
+
- **Consumer Lag**: `nats consumer info <stream> <durable>`
|
193
|
+
- **Outbox Growth**: Alert if `jetstream_outbox_events` grows unexpectedly
|
194
|
+
- **DLQ Messages**: Monitor `data.sync.dlq` subscription
|
195
|
+
|
196
|
+
### Scaling
|
197
|
+
- Run consumers in **separate processes/containers**
|
198
|
+
- Scale independently of web workers
|
199
|
+
|
200
|
+
### When to Use
|
201
|
+
- **Inbox**: When replays or duplicates are possible
|
202
|
+
- **Outbox**: When "DB commit โ event published" guarantee is required
|
203
|
+
|
204
|
+
---
|
205
|
+
|
206
|
+
## ๐ Getting Started
|
207
|
+
|
208
|
+
1. Install the gem
|
209
|
+
2. Configure the initializer
|
210
|
+
3. Run migrations: `rails db:migrate`
|
211
|
+
4. Start publishing/consuming!
|
212
|
+
|
213
|
+
---
|
214
|
+
|
215
|
+
## ๐ License
|
216
|
+
|
217
|
+
[MIT License](LICENSE)
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/jetstream_bridge/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'jetstream_bridge'
|
7
|
+
spec.version = JetstreamBridge::VERSION
|
8
|
+
spec.authors = ['Mike Attara']
|
9
|
+
spec.email = ['mpyebattara@gmail.com']
|
10
|
+
|
11
|
+
# Clear, value-focused copy
|
12
|
+
spec.summary = 'Reliable realtime bridge over NATS JetStream for Rails/Ruby apps'
|
13
|
+
spec.description = <<~DESC.strip
|
14
|
+
Publisher/Consumer utilities for NATS JetStream with environment-scoped subjects,
|
15
|
+
overlap guards, DLQ routing, retries/backoff, and optional Inbox/Outbox patterns.
|
16
|
+
Includes topology setup helpers for production-safe operation.
|
17
|
+
DESC
|
18
|
+
|
19
|
+
spec.license = 'MIT'
|
20
|
+
spec.homepage = 'https://github.com/attaradev/jetstream_bridge'
|
21
|
+
|
22
|
+
# Ruby & RubyGems requirements
|
23
|
+
spec.required_ruby_version = '>= 2.7.0'
|
24
|
+
spec.required_rubygems_version = '>= 3.3.0'
|
25
|
+
|
26
|
+
# Rich metadata for RubyGems.org
|
27
|
+
spec.metadata = {
|
28
|
+
'homepage_uri' => 'https://github.com/attaradev/jetstream_bridge',
|
29
|
+
'source_code_uri' => 'https://github.com/attaradev/jetstream_bridge',
|
30
|
+
'changelog_uri' => 'https://github.com/attaradev/jetstream_bridge/blob/main/CHANGELOG.md',
|
31
|
+
'documentation_uri' => 'https://github.com/attaradev/jetstream_bridge#readme',
|
32
|
+
'bug_tracker_uri' => 'https://github.com/attaradev/jetstream_bridge/issues',
|
33
|
+
'github_repo' => 'ssh://github.com/attaradev/jetstream_bridge',
|
34
|
+
'rubygems_mfa_required'=> 'true'
|
35
|
+
}
|
36
|
+
|
37
|
+
# Safer file list for published gem
|
38
|
+
# (falls back to Dir[] if not in a git repo โ e.g., CI tarballs)
|
39
|
+
spec.files = if system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
|
40
|
+
`git ls-files -z`.split("\x0").reject { |f| f.start_with?('spec/fixtures/') }
|
41
|
+
else
|
42
|
+
Dir['lib/**/*', 'README*', 'CHANGELOG*', 'LICENSE*']
|
43
|
+
end
|
44
|
+
|
45
|
+
spec.require_paths = ['lib']
|
46
|
+
|
47
|
+
# Runtime dependencies
|
48
|
+
spec.add_dependency 'activerecord', '>= 6.0'
|
49
|
+
spec.add_dependency 'activesupport', '>= 6.0'
|
50
|
+
spec.add_dependency 'nats-pure', '~> 2.4'
|
51
|
+
spec.add_dependency 'rails', '>= 6.0'
|
52
|
+
|
53
|
+
# Development / quality dependencies
|
54
|
+
spec.add_development_dependency 'rake', '>= 13.0'
|
55
|
+
spec.add_development_dependency 'rspec', '>= 3.12'
|
56
|
+
spec.add_development_dependency 'rubocop', '~> 1.66'
|
57
|
+
spec.add_development_dependency 'rubocop-performance', '~> 1.21'
|
58
|
+
spec.add_development_dependency 'rubocop-rake', '~> 0.6'
|
59
|
+
spec.add_development_dependency 'rubocop-packaging', '~> 0.5'
|
60
|
+
spec.add_development_dependency 'bundler-audit', '>= 0.9.1'
|
61
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JetstreamBridge
|
4
|
+
class Config
|
5
|
+
attr_accessor :destination_app, :nats_urls, :env, :app_name,
|
6
|
+
:max_deliver, :ack_wait, :backoff,
|
7
|
+
:use_outbox, :use_inbox, :inbox_model, :outbox_model,
|
8
|
+
:use_dlq
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
12
|
+
@env = ENV['NATS_ENV'] || 'development'
|
13
|
+
@app_name = ENV['APP_NAME'] || 'app'
|
14
|
+
@destination_app = ENV['DESTINATION_APP']
|
15
|
+
|
16
|
+
@max_deliver = 5
|
17
|
+
@ack_wait = '30s'
|
18
|
+
@backoff = %w[1s 5s 15s 30s 60s]
|
19
|
+
|
20
|
+
@use_outbox = false
|
21
|
+
@use_inbox = false
|
22
|
+
@use_dlq = true
|
23
|
+
@outbox_model = 'JetstreamBridge::OutboxEvent'
|
24
|
+
@inbox_model = 'JetstreamBridge::InboxEvent'
|
25
|
+
end
|
26
|
+
|
27
|
+
# Single stream name per env
|
28
|
+
def stream_name
|
29
|
+
"#{env}-jetstream-bridge-stream"
|
30
|
+
end
|
31
|
+
|
32
|
+
# Base subjects
|
33
|
+
# Producer publishes to: {env}.data.sync.{app}.{dest}
|
34
|
+
# Consumer subscribes to: {env}.data.sync.{dest}.{app}
|
35
|
+
def source_subject
|
36
|
+
"#{env}.data.sync.#{app_name}.#{destination_app}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def destination_subject
|
40
|
+
"#{env}.data.sync.#{destination_app}.#{app_name}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# DLQ
|
44
|
+
def dlq_subject
|
45
|
+
"#{env}.data.sync.dlq"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nats/io/client'
|
4
|
+
require 'singleton'
|
5
|
+
require 'json'
|
6
|
+
require_relative 'duration'
|
7
|
+
require_relative 'logging'
|
8
|
+
require_relative 'topology'
|
9
|
+
require_relative 'config'
|
10
|
+
|
11
|
+
module JetstreamBridge
|
12
|
+
# Singleton connection to NATS.
|
13
|
+
class Connection
|
14
|
+
include Singleton
|
15
|
+
|
16
|
+
DEFAULT_CONN_OPTS = {
|
17
|
+
reconnect: true,
|
18
|
+
reconnect_time_wait: 2,
|
19
|
+
max_reconnect_attempts: 10,
|
20
|
+
connect_timeout: 5
|
21
|
+
}.freeze
|
22
|
+
|
23
|
+
class << self
|
24
|
+
# Thread-safe delegator to the singleton instance
|
25
|
+
def connect!
|
26
|
+
@__mutex ||= Mutex.new
|
27
|
+
@__mutex.synchronize { instance.connect! }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Idempotent: returns an existing, healthy JetStream context or establishes one.
|
32
|
+
def connect!
|
33
|
+
return @jts if connected?
|
34
|
+
|
35
|
+
servers = nats_servers
|
36
|
+
raise 'No NATS URLs configured' if servers.empty?
|
37
|
+
|
38
|
+
establish_connection(servers)
|
39
|
+
Logging.info(
|
40
|
+
"Connected to NATS (#{servers.size} server#{servers.size == 1 ? '' : 's'}): #{sanitize_urls(servers).join(',')}",
|
41
|
+
tag: 'JetstreamBridge::Connection'
|
42
|
+
)
|
43
|
+
|
44
|
+
Topology.ensure!(@jts)
|
45
|
+
@jts
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def connected?
|
51
|
+
@nc&.connected?
|
52
|
+
end
|
53
|
+
|
54
|
+
def nats_servers
|
55
|
+
JetstreamBridge.config.nats_urls
|
56
|
+
.to_s
|
57
|
+
.split(',')
|
58
|
+
.map(&:strip)
|
59
|
+
.reject(&:empty?)
|
60
|
+
end
|
61
|
+
|
62
|
+
def establish_connection(servers)
|
63
|
+
@nc = NATS::IO::Client.new
|
64
|
+
@nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
|
65
|
+
@jts = @nc.jetstream
|
66
|
+
end
|
67
|
+
|
68
|
+
# Mask credentials in NATS URLs:
|
69
|
+
# - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
|
70
|
+
# - "nats://token@host:4222" -> "nats://***@host:4222"
|
71
|
+
def sanitize_urls(urls)
|
72
|
+
urls.map { |u| Logging.sanitize_url(u) }
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
require_relative 'connection'
|
6
|
+
require_relative 'duration'
|
7
|
+
require_relative 'logging'
|
8
|
+
require_relative 'consumer_config'
|
9
|
+
require_relative 'message_processor'
|
10
|
+
require_relative 'config'
|
11
|
+
|
12
|
+
module JetstreamBridge
|
13
|
+
# Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
|
14
|
+
class Consumer
|
15
|
+
DEFAULT_BATCH_SIZE = 25
|
16
|
+
FETCH_TIMEOUT_SECS = 5
|
17
|
+
IDLE_SLEEP_SECS = 0.05
|
18
|
+
|
19
|
+
# @param durable_name [String] Consumer name
|
20
|
+
# @param batch_size [Integer] Max messages per fetch
|
21
|
+
# @yield [event, subject, deliveries] Message handler
|
22
|
+
def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
|
23
|
+
@handler = block
|
24
|
+
@batch_size = batch_size
|
25
|
+
@durable = durable_name
|
26
|
+
@jts = Connection.connect!
|
27
|
+
|
28
|
+
ensure_destination!
|
29
|
+
ensure_consumer!
|
30
|
+
subscribe!
|
31
|
+
@processor = MessageProcessor.new(@jts, @handler)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Starts the consumer loop.
|
35
|
+
def run!
|
36
|
+
Logging.info("Consumer #{@durable} startedโฆ", tag: 'JetstreamBridge::Consumer')
|
37
|
+
loop do
|
38
|
+
processed = process_batch
|
39
|
+
sleep(IDLE_SLEEP_SECS) if processed.zero?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ensure_destination!
|
46
|
+
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
47
|
+
raise ArgumentError, 'destination_app must be configured'
|
48
|
+
end
|
49
|
+
|
50
|
+
def stream_name
|
51
|
+
JetstreamBridge.config.stream_name
|
52
|
+
end
|
53
|
+
|
54
|
+
def filter_subject
|
55
|
+
JetstreamBridge.config.destination_subject
|
56
|
+
end
|
57
|
+
|
58
|
+
def ensure_consumer!
|
59
|
+
@jts.consumer_info(stream_name, @durable)
|
60
|
+
Logging.info("Consumer #{@durable} exists.", tag: 'JetstreamBridge::Consumer')
|
61
|
+
rescue NATS::JetStream::Error
|
62
|
+
@jts.add_consumer(stream_name, **ConsumerConfig.consumer_config(@durable, filter_subject))
|
63
|
+
Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
|
64
|
+
tag: 'JetstreamBridge::Consumer')
|
65
|
+
end
|
66
|
+
|
67
|
+
def subscribe!
|
68
|
+
@psub = @jts.pull_subscribe(
|
69
|
+
filter_subject,
|
70
|
+
@durable,
|
71
|
+
stream: stream_name,
|
72
|
+
config: ConsumerConfig.subscribe_config
|
73
|
+
)
|
74
|
+
Logging.info("Subscribed to #{filter_subject} (durable=#{@durable})",
|
75
|
+
tag: 'JetstreamBridge::Consumer')
|
76
|
+
end
|
77
|
+
|
78
|
+
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
79
|
+
def process_batch
|
80
|
+
msgs = @psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
81
|
+
msgs.each { |m| @processor.handle_message(m) }
|
82
|
+
msgs.size
|
83
|
+
rescue NATS::Timeout, NATS::IO::Timeout
|
84
|
+
0
|
85
|
+
rescue NATS::JetStream::Error => e
|
86
|
+
# Handle common recoverable states by re-ensuring consumer & subscription.
|
87
|
+
if recoverable_consumer_error?(e)
|
88
|
+
Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
|
89
|
+
tag: 'JetstreamBridge::Consumer')
|
90
|
+
ensure_consumer!
|
91
|
+
subscribe!
|
92
|
+
0
|
93
|
+
else
|
94
|
+
Logging.error("Fetch failed: #{e.class} #{e.message}",
|
95
|
+
tag: 'JetstreamBridge::Consumer')
|
96
|
+
0
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def recoverable_consumer_error?(error)
|
101
|
+
msg = error.message.to_s
|
102
|
+
msg =~ /consumer.*(not\s+found|deleted)/i ||
|
103
|
+
msg =~ /no\s+responders/i
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'duration'
|
4
|
+
require_relative 'logging'
|
5
|
+
require_relative 'config'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
# Consumer configuration helpers.
|
9
|
+
module ConsumerConfig
|
10
|
+
module_function
|
11
|
+
|
12
|
+
def consumer_config(durable, filter_subject)
|
13
|
+
{
|
14
|
+
durable_name: durable,
|
15
|
+
filter_subject: filter_subject,
|
16
|
+
ack_policy: 'explicit',
|
17
|
+
max_deliver: JetstreamBridge.config.max_deliver,
|
18
|
+
ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
|
19
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
def subscribe_config
|
24
|
+
{
|
25
|
+
ack_policy: 'explicit',
|
26
|
+
max_deliver: JetstreamBridge.config.max_deliver,
|
27
|
+
ack_wait: Duration.to_millis(JetstreamBridge.config.ack_wait),
|
28
|
+
backoff: Array(JetstreamBridge.config.backoff).map { |d| Duration.to_millis(d) }
|
29
|
+
}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'overlap_guard'
|
4
|
+
require_relative 'logging'
|
5
|
+
require_relative 'subject_matcher'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
# Ensures the DLQ subject is added to the stream.
|
9
|
+
class DLQ
|
10
|
+
def self.ensure!(jts)
|
11
|
+
name = JetstreamBridge.config.stream_name
|
12
|
+
info = jts.stream_info(name)
|
13
|
+
subs = Array(info.config.subjects || [])
|
14
|
+
dlq = JetstreamBridge.config.dlq_subject
|
15
|
+
return if SubjectMatcher.covered?(subs, dlq)
|
16
|
+
|
17
|
+
desired = (subs + [dlq]).uniq
|
18
|
+
OverlapGuard.check!(jts, name, desired)
|
19
|
+
|
20
|
+
jts.update_stream(name: name, subjects: desired)
|
21
|
+
Logging.info("Added DLQ subject #{dlq} to stream #{name}", tag: 'JetstreamBridge::DLQ')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# JetstreamBridge
|
4
|
+
#
|
5
|
+
module JetstreamBridge
|
6
|
+
# Utility for parsing human-friendly durations into milliseconds.
|
7
|
+
# Examples:
|
8
|
+
# Duration.to_millis(30) #=> 30000
|
9
|
+
# Duration.to_millis("30s") #=> 30000
|
10
|
+
# Duration.to_millis("500ms") #=> 500
|
11
|
+
# Duration.to_millis(0.5) #=> 500
|
12
|
+
module Duration
|
13
|
+
MULTIPLIER = { 'ms' => 1, 's' => 1_000, 'm' => 60_000, 'h' => 3_600_000 }.freeze
|
14
|
+
NUMBER_RE = /\A\d+\z/.freeze
|
15
|
+
TOKEN_RE = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h)\z/i.freeze
|
16
|
+
|
17
|
+
module_function
|
18
|
+
|
19
|
+
def to_millis(val)
|
20
|
+
return int_to_ms(val) if val.is_a?(Integer)
|
21
|
+
return float_to_ms(val) if val.is_a?(Float)
|
22
|
+
return string_to_ms(val) if val.is_a?(String)
|
23
|
+
return float_to_ms(val.to_f) if val.respond_to?(:to_f)
|
24
|
+
|
25
|
+
raise ArgumentError, "invalid duration type: #{val.class}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def int_to_ms(i)
|
29
|
+
i >= 1_000 ? i : i * 1_000
|
30
|
+
end
|
31
|
+
|
32
|
+
def float_to_ms(f)
|
33
|
+
(f * 1_000).round
|
34
|
+
end
|
35
|
+
|
36
|
+
def string_to_ms(str)
|
37
|
+
s = str.strip
|
38
|
+
return int_to_ms(s.to_i) if NUMBER_RE.match?(s)
|
39
|
+
|
40
|
+
m = TOKEN_RE.match(s)
|
41
|
+
raise ArgumentError, "invalid duration: #{str.inspect}" unless m
|
42
|
+
|
43
|
+
(m[1].to_f * MULTIPLIER.fetch(m[2].downcase)).round
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# If ActiveRecord is not available, a shim class is defined that raises
|
4
|
+
# a helpful error when used.
|
5
|
+
begin
|
6
|
+
require 'active_record'
|
7
|
+
rescue LoadError
|
8
|
+
# Ignore; we handle the lack of AR below.
|
9
|
+
end
|
10
|
+
|
11
|
+
module JetstreamBridge
|
12
|
+
# InboxEvent is the default ActiveRecord model used by the gem when
|
13
|
+
# `use_inbox` is enabled.
|
14
|
+
# It records processed event IDs for idempotency.
|
15
|
+
if defined?(ActiveRecord::Base)
|
16
|
+
class InboxEvent < ActiveRecord::Base
|
17
|
+
self.table_name = 'jetstream_inbox_events'
|
18
|
+
|
19
|
+
validates :event_id, presence: true, uniqueness: true
|
20
|
+
validates :subject, presence: true
|
21
|
+
end
|
22
|
+
else
|
23
|
+
# Shim that fails loudly if the app misconfigures the gem without AR.
|
24
|
+
class InboxEvent
|
25
|
+
class << self
|
26
|
+
def method_missing(method_name, *_args, &_block)
|
27
|
+
raise_missing_ar!('Inbox', method_name)
|
28
|
+
end
|
29
|
+
|
30
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
31
|
+
false
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def raise_missing_ar!(which, method_name)
|
37
|
+
raise(
|
38
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
39
|
+
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
40
|
+
'`gem "activerecord"` to your Gemfile.'
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|