jetstream_bridge 7.0.6 → 7.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d405fd265deca0547e5ce7384b99c5c229cff755859981171fbc0a24cb9bea96
4
- data.tar.gz: 458d7ecc088dde7691be9e2b0f0c00b1a185539ec6456eadc66579639089728a
3
+ metadata.gz: 65a794cf3e9a17511c64f2ef29edc70d56214f4cfc1c63eed8148afb65e05505
4
+ data.tar.gz: 44dea5d3d1900999fc788aad9e9d676a02315c18678256f4ce14d5443cb0b816
5
5
  SHA512:
6
- metadata.gz: 1da0a56666a6bb35c892dbd1f3fcacba77a1428a3ce09d9fa74de2fbe2ac309325b2f9b3a42360d500fa4f74ad3b180a3581f521fab3df5ba6fa4cbe3d076fcf
7
- data.tar.gz: 82efe7aace08352be950b7b7389bde00d0eb603041445af90e810c86567884139db6ce716873aadecbd15251e3f5d033b58a1f9acfa451d579b5f8d13eb3d588
6
+ metadata.gz: 360c547ece5e7443487c3796d18139843ea86a2ce7f29a775fd1245d6e084d76bec11df72cdafef78ac9fa383ad99eae680f9220dc9eef451f62ba012fe20de9
7
+ data.tar.gz: 62e2100993ba1defc32df1ad744392148f4600527c5fab80db6cce2d211b80c79d6228c64497e61106ba13fdef9579300df1455d5caf3d4e75a83ccd896a5051
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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.1] - 2026-02-02
9
+
10
+ ### Changed
11
+
12
+ - Fail-fast consumer provisioning: when running in push mode with `auto_provision=false`, the bridge now raises `ConsumerProvisioningError` instead of silently skipping consumer setup, preventing idle workers when the durable is missing.
13
+ - Permission-aware creation: consumer creation now surfaces `ConsumerProvisioningError` on permissions violations (pull or push mode), guiding operators to grant the minimal `$JS.API` rights or pre-provision the durable.
14
+
15
+ ### Added
16
+
17
+ - New `ConsumerProvisioningError` topology error for clearer operator feedback when consumer setup cannot proceed.
18
+
19
+ ## [7.1.0] - 2026-02-01
20
+
21
+ ### Added
22
+
23
+ - **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.
24
+ - `SubscriptionManager#stream_exists?` - Public method to check if a stream exists.
25
+ - `SubscriptionManager#consumer_exists?` - Public method to check if a consumer exists in the stream.
26
+ - `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.
27
+
28
+ ### Changed
29
+
30
+ - `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.
31
+ - Consumer recovery automatically attempts to create the consumer when a "consumer not found" error is detected.
32
+ - **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.
33
+
8
34
  ## [7.0.0] - 2026-01-30
9
35
 
10
36
  ### 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/consumer provisioning with overlap protection.
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` plus pre-created consumers.
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.0"
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/consumer on startup
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
@@ -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.0"
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 + consumer once with admin credentials (CLI below or `bundle exec rake jetstream_bridge:provision`)
42
- 1. **Pre-create the consumer** using a privileged NATS account
43
- 2. **Ensure the consumer configuration matches** what your app expects
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
- > Tip: When `auto_provision=false`, the app still connects/publishes/consumes but skips JetStream management APIs (account_info, stream_info, consumer_info). Health checks will report basic connectivity only.
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. **Consumer doesn't exist** - Pre-create it with NATS CLI
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. **Consumer pre-creation requires privileged access** - Keep admin credentials secure
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. **No automatic recovery** - If consumer is deleted, it won't be recreated automatically
558
- 4. **Consider automation** - Use infrastructure-as-code (Terraform, Ansible) to manage consumer creation
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
 
@@ -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,107 @@ module JetstreamBridge
28
28
  @desired_cfg
29
29
  end
30
30
 
31
- def ensure_consumer!(force: false)
32
- # Runtime path: never hit JetStream management APIs to avoid admin permissions.
33
- unless force || @cfg.auto_provision
34
- log_runtime_skip
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
+ # In restricted environments (push + auto_provision=false), we still want fail-fast semantics.
83
+ # Attempt creation and raise a clear error so operators know the consumer must be pre-provisioned
84
+ # or the account must allow the minimal $JS.API consumer permissions.
85
+ if skip_consumer_management?
86
+ raise JetstreamBridge::ConsumerProvisioningError,
87
+ "Consumer '#{@durable}' not ensured because auto_provision=false and push mode is enabled. " \
88
+ 'Provision the consumer with admin credentials or grant minimal consumer create/info permissions.'
89
+ end
90
+
91
+ # First, verify stream exists - fail fast with clear error if not
92
+ unless stream_exists?
93
+ raise StreamNotFoundError,
94
+ "Stream '#{stream_name}' does not exist. " \
95
+ 'Streams must be provisioned separately ' \
96
+ '(use auto_provision=true or run provisioning with admin credentials).'
97
+ end
98
+
99
+ if consumer_exists?
100
+ Logging.info(
101
+ "Consumer #{@durable} already exists (stream=#{stream_name})",
102
+ tag: 'JetstreamBridge::Consumer'
103
+ )
35
104
  return
36
105
  end
37
106
 
107
+ Logging.info(
108
+ "Consumer #{@durable} not found, auto-creating on stream #{stream_name}...",
109
+ tag: 'JetstreamBridge::Consumer'
110
+ )
38
111
  create_consumer!
112
+ rescue StreamNotFoundError
113
+ raise
114
+ rescue StandardError => e
115
+ raise ConsumerProvisioningError, permission_error_message(e) if permission_denied?(e)
116
+
117
+ # If creation fails due to consumer already existing (race condition), that's OK
118
+ msg = e.message.to_s.downcase
119
+ if msg.include?('already') || msg.include?('exists')
120
+ Logging.info(
121
+ "Consumer #{@durable} was created by another process",
122
+ tag: 'JetstreamBridge::Consumer'
123
+ )
124
+ return
125
+ end
126
+
127
+ Logging.error(
128
+ "Failed to auto-create consumer #{@durable}: #{e.class} #{e.message}",
129
+ tag: 'JetstreamBridge::Consumer'
130
+ )
131
+ raise
39
132
  end
40
133
 
41
134
  # Bind a subscriber to the existing durable consumer.
@@ -128,6 +221,21 @@ module JetstreamBridge
128
221
  config
129
222
  end
130
223
 
224
+ def skip_consumer_management?
225
+ @cfg.push_consumer? && !@cfg.auto_provision
226
+ end
227
+
228
+ def permission_denied?(error)
229
+ msg = error.message.to_s.downcase
230
+ msg.include?('permission') || msg.include?('permissions violation')
231
+ end
232
+
233
+ def permission_error_message(error)
234
+ "Consumer '#{@durable}' could not be ensured due to permissions: #{error.message}. " \
235
+ 'Grant $JS.API.STREAM.INFO/$JS.API.CONSUMER.INFO/$JS.API.CONSUMER.CREATE for the stream, ' \
236
+ 'or pre-provision the consumer with an admin account.'
237
+ end
238
+
131
239
  def create_consumer!
132
240
  @jts.add_consumer(stream_name, **desired_consumer_cfg)
133
241
  Logging.info(
@@ -136,14 +244,6 @@ module JetstreamBridge
136
244
  )
137
245
  end
138
246
 
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
247
  def resolve_nc
148
248
  return @jts.nc if @jts.respond_to?(:nc)
149
249
  return @jts.instance_variable_get(:@nc) if @jts.instance_variable_defined?(:@nc)
@@ -67,6 +67,7 @@ module JetstreamBridge
67
67
 
68
68
  # Topology errors
69
69
  class TopologyError < Error; end
70
+ class ConsumerProvisioningError < TopologyError; end
70
71
  class StreamNotFoundError < TopologyError; end
71
72
  class SubjectOverlapError < TopologyError; end
72
73
  class StreamCreationFailedError < TopologyError; end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '7.0.6'
7
+ VERSION = '7.1.1'
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jetstream_bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.6
4
+ version: 7.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-01 00:00:00.000000000 Z
11
+ date: 2026-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord