jetstream_bridge 7.0.5 → 7.1.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/CHANGELOG.md +15 -0
- data/README.md +3 -3
- data/docs/API.md +37 -1
- data/docs/GETTING_STARTED.md +9 -1
- data/docs/RESTRICTED_PERMISSIONS.md +13 -9
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +5 -4
- data/lib/jetstream_bridge/consumer/consumer.rb +24 -0
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +86 -12
- data/lib/jetstream_bridge/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 290f604d47d1605f6c2b2abcde100159327a7418b103c0c054b0b92f5e7fbf56
|
|
4
|
+
data.tar.gz: 485e9cf4070d9671a1994ffbde32ab8ca6e41fbfb7d94d8cf571e2e8ec04cd3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a1a17ed6a75d8f9b32de3efbe23da1797824c4e846c0d4d73ee1014d9ea20048367088cfaabd3f9a02179314090741237d21dffc0c618a429a47510597bb1ed8
|
|
7
|
+
data.tar.gz: f3ae4a9e9b256f4dfa1d1e84a32d66140ae01c52d07f21b8fac7976c61bb76a81cb83a53aae6631af9c0affa81eb24d81b7167ebb18875489e398f7869ebd2d6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [7.1.0] - 2026-02-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Auto-create consumer on subscription** - Consumers are now automatically created if they don't exist when subscribing, regardless of the `auto_provision` setting. This eliminates the need for manual consumer provisioning while `auto_provision` continues to control only stream topology creation.
|
|
13
|
+
- `SubscriptionManager#stream_exists?` - Public method to check if a stream exists.
|
|
14
|
+
- `SubscriptionManager#consumer_exists?` - Public method to check if a consumer exists in the stream.
|
|
15
|
+
- `SubscriptionManager#create_consumer_if_missing!` - Public method to create a consumer only if it doesn't already exist (race-condition safe). Raises `StreamNotFoundError` if the stream doesn't exist.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `ensure_consumer!` now always auto-creates the consumer if it doesn't exist. The `auto_provision` setting only controls stream topology (stream creation), not consumer creation.
|
|
20
|
+
- Consumer recovery automatically attempts to create the consumer when a "consumer not found" error is detected.
|
|
21
|
+
- **Stream must exist** - Auto-create consumer fails fast with `StreamNotFoundError` if the stream doesn't exist. Streams must be provisioned separately via `auto_provision=true` or manual provisioning.
|
|
22
|
+
|
|
8
23
|
## [7.0.0] - 2026-01-30
|
|
9
24
|
|
|
10
25
|
### Added
|
data/README.md
CHANGED
|
@@ -26,16 +26,16 @@
|
|
|
26
26
|
|
|
27
27
|
- Transactional outbox and idempotent inbox (optional) for exactly-once pipelines.
|
|
28
28
|
- Durable pull (default) or push consumers with retries, backoff, and DLQ routing.
|
|
29
|
-
- Auto stream
|
|
29
|
+
- Auto stream provisioning with overlap protection; consumers auto-created on subscription.
|
|
30
30
|
- Rails-native: generators, migrations, health check, and eager-loading safety.
|
|
31
|
-
- Least-privilege friendly: run with `auto_provision=false`
|
|
31
|
+
- Least-privilege friendly: run with `auto_provision=false` (stream must exist; consumers are auto-created).
|
|
32
32
|
- Mock NATS for fast, no-infra testing.
|
|
33
33
|
|
|
34
34
|
## Quick Start
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
37
|
# Gemfile
|
|
38
|
-
gem "jetstream_bridge", "~> 7.
|
|
38
|
+
gem "jetstream_bridge", "~> 7.1"
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
```bash
|
data/docs/API.md
CHANGED
|
@@ -40,7 +40,7 @@ JetstreamBridge.configure do |config|
|
|
|
40
40
|
config.backoff = ["1s", "5s", "15s", "30s", "60s"]
|
|
41
41
|
|
|
42
42
|
# Provisioning
|
|
43
|
-
config.auto_provision = true # Auto-create stream
|
|
43
|
+
config.auto_provision = true # Auto-create stream on startup (consumers are always auto-created)
|
|
44
44
|
|
|
45
45
|
# Connection behavior
|
|
46
46
|
config.lazy_connect = false # Set true to skip autostart
|
|
@@ -224,6 +224,28 @@ provisioner.provision_stream!
|
|
|
224
224
|
provisioner.provision_consumer!
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
+
### `JetstreamBridge::SubscriptionManager`
|
|
228
|
+
|
|
229
|
+
Manages consumer lifecycle during subscription. Used internally by `Consumer`, but also available for advanced use cases.
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# Create subscription manager
|
|
233
|
+
sub_mgr = JetstreamBridge::SubscriptionManager.new(jetstream_context, "my-durable")
|
|
234
|
+
|
|
235
|
+
# Check if stream exists
|
|
236
|
+
sub_mgr.stream_exists? # => true/false
|
|
237
|
+
|
|
238
|
+
# Check if consumer exists
|
|
239
|
+
sub_mgr.consumer_exists? # => true/false
|
|
240
|
+
|
|
241
|
+
# Create consumer if missing (raises StreamNotFoundError if stream doesn't exist)
|
|
242
|
+
sub_mgr.create_consumer_if_missing!
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Note:** Consumers are automatically created when subscribing, regardless of the `auto_provision` setting. The `auto_provision` setting only controls stream topology creation.
|
|
246
|
+
|
|
247
|
+
**Raises:** `JetstreamBridge::StreamNotFoundError` if the stream doesn't exist when calling `create_consumer_if_missing!`
|
|
248
|
+
|
|
227
249
|
## Health & Diagnostics
|
|
228
250
|
|
|
229
251
|
### `JetstreamBridge.health_check`
|
|
@@ -347,6 +369,20 @@ rescue JetstreamBridge::PublishError => e
|
|
|
347
369
|
end
|
|
348
370
|
```
|
|
349
371
|
|
|
372
|
+
### `JetstreamBridge::StreamNotFoundError`
|
|
373
|
+
|
|
374
|
+
Raised when attempting to create a consumer on a stream that doesn't exist.
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
begin
|
|
378
|
+
consumer = JetstreamBridge::Consumer.new { |event| process(event) }
|
|
379
|
+
consumer.run!
|
|
380
|
+
rescue JetstreamBridge::StreamNotFoundError => e
|
|
381
|
+
logger.error("Stream not found: #{e.message}")
|
|
382
|
+
# Stream must be provisioned separately (use auto_provision=true or run provisioning with admin credentials)
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
350
386
|
### Custom Error Handler
|
|
351
387
|
|
|
352
388
|
```ruby
|
data/docs/GETTING_STARTED.md
CHANGED
|
@@ -6,7 +6,7 @@ This guide covers installation, Rails setup, configuration, and basic publish/co
|
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
8
|
# Gemfile
|
|
9
|
-
gem "jetstream_bridge", "~> 7.
|
|
9
|
+
gem "jetstream_bridge", "~> 7.1"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
```bash
|
|
@@ -109,6 +109,12 @@ JetstreamBridge.configure do |config|
|
|
|
109
109
|
config.max_deliver = 5
|
|
110
110
|
config.ack_wait = "30s"
|
|
111
111
|
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
112
|
+
|
|
113
|
+
# Provisioning (default: true)
|
|
114
|
+
# - When true: auto-creates stream on startup
|
|
115
|
+
# - When false: stream must exist (provision separately with admin credentials)
|
|
116
|
+
# Note: Consumers are always auto-created on subscription regardless of this setting
|
|
117
|
+
config.auto_provision = true
|
|
112
118
|
end
|
|
113
119
|
|
|
114
120
|
# Note: `configure` only sets options; it does not connect. Rails will start
|
|
@@ -119,6 +125,8 @@ end
|
|
|
119
125
|
|
|
120
126
|
Rails autostart runs after initialization (including in console). You can opt out for rake tasks or other tooling with `config.lazy_connect = true` or `JETSTREAM_BRIDGE_DISABLE_AUTOSTART=1`; it will then connect on first publish/subscribe.
|
|
121
127
|
|
|
128
|
+
**Important:** When `auto_provision=false`, the stream must exist before subscribing. Consumers are automatically created when they don't exist, but attempting to create a consumer on a missing stream will raise `StreamNotFoundError`.
|
|
129
|
+
|
|
122
130
|
### Push consumer mode (restricted credentials)
|
|
123
131
|
|
|
124
132
|
If your NATS user cannot publish to `$JS.API.*`, switch to push consumers and pre-create the durable consumer:
|
|
@@ -36,13 +36,15 @@ When you cannot modify NATS server permissions, you have **two options**:
|
|
|
36
36
|
|
|
37
37
|
For both options, you need to:
|
|
38
38
|
|
|
39
|
-
0. **Turn off runtime provisioning** so the app never calls `$JS.API
|
|
39
|
+
0. **Turn off runtime provisioning** so the app never calls `$JS.API.*` for stream creation:
|
|
40
40
|
- Set `config.auto_provision = false`
|
|
41
|
-
- Provision stream
|
|
42
|
-
1. **
|
|
43
|
-
2. **
|
|
41
|
+
- Provision the **stream** once with admin credentials (CLI below or `bundle exec rake jetstream_bridge:provision`)
|
|
42
|
+
1. **Ensure the stream exists** - consumers are auto-created when subscribing, but the stream must be provisioned separately
|
|
43
|
+
2. **Optionally pre-create the consumer** using a privileged NATS account (consumers are auto-created if missing)
|
|
44
44
|
|
|
45
|
-
>
|
|
45
|
+
> **Note (v7.1.0+):** Consumers are now auto-created on subscription regardless of the `auto_provision` setting. The `auto_provision` setting only controls stream topology creation. If the stream doesn't exist, subscription will fail with `StreamNotFoundError`.
|
|
46
|
+
|
|
47
|
+
> Tip: When `auto_provision=false`, the app still connects/publishes/consumes but skips JetStream management APIs (account_info, stream_info) for stream creation. Health checks will report basic connectivity only.
|
|
46
48
|
|
|
47
49
|
---
|
|
48
50
|
|
|
@@ -543,19 +545,21 @@ nats stream info pwas-heavyworth-sync
|
|
|
543
545
|
|
|
544
546
|
Check if the issue is:
|
|
545
547
|
|
|
546
|
-
1. **
|
|
548
|
+
1. **Stream doesn't exist** - Provision it with NATS CLI or admin credentials (consumers are auto-created)
|
|
547
549
|
2. **Filter subject mismatch** - Verify with `nats consumer info`
|
|
548
550
|
3. **Permissions still insufficient** - User needs subscribe permissions on the filtered subject
|
|
549
551
|
4. **Wrong stream name** - Ensure stream name matches your configured `config.stream_name`
|
|
550
552
|
|
|
553
|
+
**Note (v7.1.0+):** Consumers are auto-created if they don't exist, so "consumer doesn't exist" is no longer a common cause. However, the **stream must exist** or you'll see `StreamNotFoundError`.
|
|
554
|
+
|
|
551
555
|
---
|
|
552
556
|
|
|
553
557
|
## Security Considerations
|
|
554
558
|
|
|
555
|
-
1. **
|
|
559
|
+
1. **Stream pre-creation requires privileged access** - Keep admin credentials secure
|
|
556
560
|
2. **Configuration drift risk** - If app config doesn't match consumer config, message delivery may fail silently
|
|
557
|
-
3. **
|
|
558
|
-
4. **Consider automation** - Use infrastructure-as-code (Terraform, Ansible) to manage
|
|
561
|
+
3. **Automatic consumer recovery (v7.1.0+)** - If consumer is deleted, it will be auto-recreated on subscription. Stream must exist.
|
|
562
|
+
4. **Consider automation** - Use infrastructure-as-code (Terraform, Ansible) to manage stream creation
|
|
559
563
|
|
|
560
564
|
---
|
|
561
565
|
|
|
@@ -40,10 +40,11 @@ module JetstreamBridge
|
|
|
40
40
|
# Prefer the Rails application configuration which is available
|
|
41
41
|
# without hitting ActiveRecord's dynamic matchers (that would raise
|
|
42
42
|
# when no connection is established).
|
|
43
|
-
if defined?(Rails) &&
|
|
44
|
-
Rails.application &&
|
|
45
|
-
Rails.application
|
|
46
|
-
|
|
43
|
+
if defined?(::Rails) &&
|
|
44
|
+
::Rails.respond_to?(:application) &&
|
|
45
|
+
::Rails.application &&
|
|
46
|
+
::Rails.application.config.respond_to?(:active_record)
|
|
47
|
+
ar_config = ::Rails.application.config.active_record
|
|
47
48
|
return ar_config.timestamped_migrations if ar_config.respond_to?(:timestamped_migrations)
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -429,6 +429,9 @@ module JetstreamBridge
|
|
|
429
429
|
tag: 'JetstreamBridge::Consumer'
|
|
430
430
|
)
|
|
431
431
|
|
|
432
|
+
# If this looks like a consumer-not-found error, try to auto-create it
|
|
433
|
+
auto_create_consumer_on_error(error) if consumer_not_found_error?(error)
|
|
434
|
+
|
|
432
435
|
sleep(backoff_secs)
|
|
433
436
|
ensure_subscription!
|
|
434
437
|
|
|
@@ -440,6 +443,27 @@ module JetstreamBridge
|
|
|
440
443
|
0
|
|
441
444
|
end
|
|
442
445
|
|
|
446
|
+
def consumer_not_found_error?(error)
|
|
447
|
+
msg = error.message.to_s.downcase
|
|
448
|
+
(msg.include?('consumer') && (msg.include?('not found') || msg.include?('does not exist'))) ||
|
|
449
|
+
msg.include?('no responders')
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def auto_create_consumer_on_error(_error)
|
|
453
|
+
Logging.info(
|
|
454
|
+
"Consumer not found error detected, attempting auto-creation for #{@durable}...",
|
|
455
|
+
tag: 'JetstreamBridge::Consumer'
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
@sub_mgr.create_consumer_if_missing!
|
|
459
|
+
rescue StandardError => e
|
|
460
|
+
Logging.warn(
|
|
461
|
+
"Auto-create consumer failed: #{e.class} #{e.message}",
|
|
462
|
+
tag: 'JetstreamBridge::Consumer'
|
|
463
|
+
)
|
|
464
|
+
# Don't re-raise - let the normal recovery flow continue
|
|
465
|
+
end
|
|
466
|
+
|
|
443
467
|
def calculate_reconnect_backoff(attempt)
|
|
444
468
|
# Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, 1.6s, ... up to 30s max
|
|
445
469
|
base_delay = 0.1
|
|
@@ -28,14 +28,96 @@ module JetstreamBridge
|
|
|
28
28
|
@desired_cfg
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
# Ensure consumer exists, auto-creating if missing.
|
|
32
|
+
#
|
|
33
|
+
# @param force [Boolean] Kept for backward compatibility but no longer used.
|
|
34
|
+
# Consumers are always auto-created regardless of this parameter.
|
|
35
|
+
def ensure_consumer!(**_options)
|
|
36
|
+
# Always auto-create consumer if it doesn't exist, regardless of auto_provision setting.
|
|
37
|
+
# auto_provision only controls stream topology creation, not consumer creation.
|
|
38
|
+
create_consumer_if_missing!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if stream exists.
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] true if stream exists, false otherwise
|
|
44
|
+
def stream_exists?
|
|
45
|
+
@jts.stream_info(stream_name)
|
|
46
|
+
true
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
msg = e.message.to_s.downcase
|
|
49
|
+
return false if msg.include?('not found') || msg.include?('does not exist') || msg.include?('no responders')
|
|
50
|
+
|
|
51
|
+
# Re-raise unexpected errors
|
|
52
|
+
raise unless msg.include?('stream')
|
|
53
|
+
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if consumer exists in the stream.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean] true if consumer exists, false otherwise
|
|
60
|
+
def consumer_exists?
|
|
61
|
+
@jts.consumer_info(stream_name, @durable)
|
|
62
|
+
true
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
msg = e.message.to_s.downcase
|
|
65
|
+
return false if msg.include?('not found') || msg.include?('does not exist') || msg.include?('no responders')
|
|
66
|
+
|
|
67
|
+
# Re-raise unexpected errors
|
|
68
|
+
raise unless msg.include?('consumer')
|
|
69
|
+
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Create consumer only if it doesn't already exist.
|
|
74
|
+
#
|
|
75
|
+
# Fails if the stream doesn't exist - streams must be provisioned separately.
|
|
76
|
+
#
|
|
77
|
+
# This is a safer alternative to create_consumer! that won't fail
|
|
78
|
+
# if the consumer was already created by another process.
|
|
79
|
+
#
|
|
80
|
+
# @raise [StreamNotFoundError] if the stream doesn't exist
|
|
81
|
+
def create_consumer_if_missing!
|
|
82
|
+
# First, verify stream exists - fail fast with clear error if not
|
|
83
|
+
unless stream_exists?
|
|
84
|
+
raise StreamNotFoundError,
|
|
85
|
+
"Stream '#{stream_name}' does not exist. " \
|
|
86
|
+
'Streams must be provisioned separately ' \
|
|
87
|
+
'(use auto_provision=true or run provisioning with admin credentials).'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if consumer_exists?
|
|
91
|
+
Logging.info(
|
|
92
|
+
"Consumer #{@durable} already exists (stream=#{stream_name})",
|
|
93
|
+
tag: 'JetstreamBridge::Consumer'
|
|
94
|
+
)
|
|
35
95
|
return
|
|
36
96
|
end
|
|
37
97
|
|
|
98
|
+
Logging.info(
|
|
99
|
+
"Consumer #{@durable} not found, auto-creating on stream #{stream_name}...",
|
|
100
|
+
tag: 'JetstreamBridge::Consumer'
|
|
101
|
+
)
|
|
38
102
|
create_consumer!
|
|
103
|
+
rescue StreamNotFoundError
|
|
104
|
+
raise
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
# If creation fails due to consumer already existing (race condition), that's OK
|
|
107
|
+
msg = e.message.to_s.downcase
|
|
108
|
+
if msg.include?('already') || msg.include?('exists')
|
|
109
|
+
Logging.info(
|
|
110
|
+
"Consumer #{@durable} was created by another process",
|
|
111
|
+
tag: 'JetstreamBridge::Consumer'
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Logging.error(
|
|
117
|
+
"Failed to auto-create consumer #{@durable}: #{e.class} #{e.message}",
|
|
118
|
+
tag: 'JetstreamBridge::Consumer'
|
|
119
|
+
)
|
|
120
|
+
raise
|
|
39
121
|
end
|
|
40
122
|
|
|
41
123
|
# Bind a subscriber to the existing durable consumer.
|
|
@@ -136,14 +218,6 @@ module JetstreamBridge
|
|
|
136
218
|
)
|
|
137
219
|
end
|
|
138
220
|
|
|
139
|
-
def log_runtime_skip
|
|
140
|
-
Logging.info(
|
|
141
|
-
"Skipping consumer provisioning/verification for #{@durable} at runtime to avoid JetStream API usage. " \
|
|
142
|
-
'Ensure it is pre-created via provisioning.',
|
|
143
|
-
tag: 'JetstreamBridge::Consumer'
|
|
144
|
-
)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
221
|
def resolve_nc
|
|
148
222
|
return @jts.nc if @jts.respond_to?(:nc)
|
|
149
223
|
return @jts.instance_variable_get(:@nc) if @jts.instance_variable_defined?(:@nc)
|