jetstream_bridge 2.2.1 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4575d208da9591190342d3252b48f7dafa5d33519230700b23b2e2b6ff20555f
4
- data.tar.gz: 35153c2cf70e7e446dfc4fb8ff2cdbe656d907d34d4769c2e9971ca10aebe766
3
+ metadata.gz: e0501a83858dfa54be16d6e9b9484564f3fc8e275f30473322e3f304501c2ca5
4
+ data.tar.gz: 6ea1b6fc4333c3e3e4cffb220669877c7271c4c54cab532a22f89e9f41ed5a51
5
5
  SHA512:
6
- metadata.gz: eb6e2726cebd8d71092dcc34e1956e9e0bdcb598f41e26badd7dbd6376e778487ec5544470d6aee44b0312f3983aa5f74c717208f6b7208296853b0c2f49a4db
7
- data.tar.gz: 53ba518040a3bb9a7883764621549cc794e8c84784f89c7c9c205de61cc662f748fd994587d6f1821ff8d110ae193b2d86b998c3dacc2127f8fd778e9e69606d
6
+ metadata.gz: a6d3749ffda0dc178218ae0e7a55ca45a93d129961fb127958699ef9fa88115814aa19954f1a38a81848de7ca678e86fbb1ba802efa00c9388da3ec444dce1e7
7
+ data.tar.gz: 3866b27b03ae500859f23cdc39bb6cd72b6e49eeefc8e0bf2e921a14097423e6439baf1ff8b45b08327331cbe387aabff3807fb139fb6f84614bd3b7b35894de
@@ -11,6 +11,7 @@
11
11
  <w>psub</w>
12
12
  <w>sname</w>
13
13
  <w>softprops</w>
14
+ <w>workqueue</w>
14
15
  </words>
15
16
  </dictionary>
16
17
  </component>
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jetstream_bridge (2.2.1)
4
+ jetstream_bridge (2.3.0)
5
5
  activerecord (>= 6.0)
6
6
  activesupport (>= 6.0)
7
7
  nats-pure (~> 2.4)
data/README.md CHANGED
@@ -23,7 +23,7 @@ Includes durable consumers, backpressure, retries, **DLQ**, optional **Inbox/Out
23
23
 
24
24
  ```ruby
25
25
  # Gemfile
26
- gem "jetstream_bridge", "~> 2.0"
26
+ gem "jetstream_bridge", "~> 2.3"
27
27
  ```
28
28
 
29
29
  ```bash
@@ -191,7 +191,7 @@ publisher.publish(
191
191
  If **Outbox** is enabled, the publish call:
192
192
 
193
193
  * Upserts an outbox row by `event_id`
194
- * Publishes with `Nats-Msg-Id` (idempotent)
194
+ * Publishes with `nats-msg-id` (idempotent)
195
195
  * Marks status `sent` or records `failed` with `last_error`
196
196
 
197
197
  ---
@@ -13,7 +13,7 @@ require_relative 'subscription_manager'
13
13
  require_relative 'inbox/inbox_processor'
14
14
 
15
15
  module JetstreamBridge
16
- # Subscribes to "{env}.{dest}.sync.{app}" and processes messages.
16
+ # Subscribes to destination subject and processes messages via a pull durable.
17
17
  class Consumer
18
18
  DEFAULT_BATCH_SIZE = 25
19
19
  FETCH_TIMEOUT_SECS = 5
@@ -29,7 +29,7 @@ module JetstreamBridge
29
29
 
30
30
  @sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
31
31
  @sub_mgr.ensure_consumer!
32
- @psub = @sub_mgr.subscribe!
32
+ @psub = @sub_mgr.subscribe!
33
33
 
34
34
  @processor = MessageProcessor.new(@jts, @handler)
35
35
  @inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
@@ -71,24 +71,28 @@ module JetstreamBridge
71
71
  msgs.sum { |m| process_one(m) }
72
72
  end
73
73
 
74
- def process_one(m)
74
+ def process_one(msg)
75
75
  if @inbox_proc
76
- @inbox_proc.process(m) ? 1 : 0
76
+ @inbox_proc.process(msg) ? 1 : 0
77
77
  else
78
- @processor.handle_message(m)
78
+ @processor.handle_message(msg)
79
79
  1
80
80
  end
81
81
  end
82
82
 
83
83
  def handle_js_error(e)
84
84
  if recoverable_consumer_error?(e)
85
- Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
86
- tag: 'JetstreamBridge::Consumer')
85
+ Logging.warn(
86
+ "Recovering subscription after error: #{e.class} #{e.message}",
87
+ tag: 'JetstreamBridge::Consumer'
88
+ )
87
89
  @sub_mgr.ensure_consumer!
88
90
  @psub = @sub_mgr.subscribe!
89
91
  else
90
- Logging.error("Fetch failed: #{e.class} #{e.message}",
91
- tag: 'JetstreamBridge::Consumer')
92
+ Logging.error(
93
+ "Fetch failed: #{e.class} #{e.message}",
94
+ tag: 'JetstreamBridge::Consumer'
95
+ )
92
96
  end
93
97
  0
94
98
  end
@@ -9,23 +9,17 @@ module JetstreamBridge
9
9
  module ConsumerConfig
10
10
  module_function
11
11
 
12
+ # Complete consumer config (pre-provisioned durable, pull mode).
12
13
  def consumer_config(durable, filter_subject)
13
14
  {
14
15
  durable_name: durable,
15
16
  filter_subject: filter_subject,
16
17
  ack_policy: 'explicit',
18
+ deliver_policy: 'all',
17
19
  max_deliver: JetstreamBridge.config.max_deliver,
18
20
  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) }
21
+ backoff: Array(JetstreamBridge.config.backoff)
22
+ .map { |d| Duration.to_millis(d) }
29
23
  }
30
24
  end
31
25
  end
@@ -5,16 +5,16 @@ require 'securerandom'
5
5
  require_relative '../core/logging'
6
6
 
7
7
  module JetstreamBridge
8
- # Handles parse → handler → ack / nak → DLQ
8
+ # Handles parse → handler → ack / nak → DLQ.
9
9
  class MessageProcessor
10
10
  def initialize(jts, handler)
11
- @jts = jts
11
+ @jts = jts
12
12
  @handler = handler
13
13
  end
14
14
 
15
15
  def handle_message(msg)
16
16
  deliveries = msg.metadata&.num_delivered.to_i
17
- event_id = msg.header&.[]('Nats-Msg-Id') || SecureRandom.uuid
17
+ event_id = msg.header&.[]('nats-msg-id') || SecureRandom.uuid
18
18
  event = parse_message(msg, event_id)
19
19
  return unless event
20
20
 
@@ -28,28 +28,34 @@ module JetstreamBridge
28
28
  rescue JSON::ParserError => e
29
29
  publish_to_dlq!(msg)
30
30
  msg.ack
31
- Logging.warn("Malformed JSON to DLQ event_id=#{event_id}: #{e.message}",
32
- tag: 'JetstreamBridge::Consumer')
31
+ Logging.warn(
32
+ "Malformed JSON to DLQ event_id=#{event_id}: #{e.message}",
33
+ tag: 'JetstreamBridge::Consumer'
34
+ )
33
35
  nil
34
36
  end
35
37
 
36
- def process_event(msg, event, deliveries, event_id)
38
+ def process_event(msg, event, deliveries, _event_id)
37
39
  @handler.call(event, msg.subject, deliveries)
38
40
  msg.ack
39
41
  rescue StandardError => e
40
- ack_or_nak(msg, deliveries, event_id, e)
42
+ ack_or_nak(msg, deliveries, e)
41
43
  end
42
44
 
43
- def ack_or_nak(msg, deliveries, event_id, error)
45
+ def ack_or_nak(msg, deliveries, error)
44
46
  if deliveries >= JetstreamBridge.config.max_deliver.to_i
45
47
  publish_to_dlq!(msg)
46
48
  msg.ack
47
- Logging.warn("Sent to DLQ after max_deliver event_id=#{event_id} err=#{error.message}",
48
- tag: 'JetstreamBridge::Consumer')
49
+ Logging.warn(
50
+ "Sent to DLQ after max_deliver err=#{error.message}",
51
+ tag: 'JetstreamBridge::Consumer'
52
+ )
49
53
  else
50
54
  msg.nak
51
- Logging.warn("NAK event_id=#{event_id} deliveries=#{deliveries} err=#{error.message}",
52
- tag: 'JetstreamBridge::Consumer')
55
+ Logging.warn(
56
+ "NAK deliveries=#{deliveries} err=#{error.message}",
57
+ tag: 'JetstreamBridge::Consumer'
58
+ )
53
59
  end
54
60
  end
55
61
 
@@ -58,8 +64,10 @@ module JetstreamBridge
58
64
 
59
65
  @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: msg.header)
60
66
  rescue StandardError => e
61
- Logging.error("DLQ publish failed: #{e.class} #{e.message}",
62
- tag: 'JetstreamBridge::Consumer')
67
+ Logging.error(
68
+ "DLQ publish failed: #{e.class} #{e.message}",
69
+ tag: 'JetstreamBridge::Consumer'
70
+ )
63
71
  end
64
72
  end
65
73
  end
@@ -4,7 +4,7 @@ require_relative '../core/logging'
4
4
  require_relative '../consumer/consumer_config'
5
5
 
6
6
  module JetstreamBridge
7
- # Encapsulates durable ensure + subscribe for a consumer.
7
+ # Encapsulates durable ensure + subscribe for a pull consumer.
8
8
  class SubscriptionManager
9
9
  def initialize(jts, durable, cfg = JetstreamBridge.config)
10
10
  @jts = jts
@@ -27,17 +27,18 @@ module JetstreamBridge
27
27
  def ensure_consumer!
28
28
  info = consumer_info_or_nil
29
29
  return create_consumer! unless info
30
- return log_consumer_ok if consumer_matches?(info)
30
+ return log_consumer_ok if consumer_matches?(info, desired_consumer_cfg)
31
31
 
32
32
  recreate_consumer!
33
33
  end
34
34
 
35
+ # Bind a pull subscriber to the existing durable.
35
36
  def subscribe!
36
37
  @jts.pull_subscribe(
37
38
  filter_subject,
38
39
  @durable,
39
40
  stream: stream_name,
40
- config: ConsumerConfig.subscribe_config
41
+ config: desired_consumer_cfg
41
42
  )
42
43
  end
43
44
 
@@ -49,16 +50,31 @@ module JetstreamBridge
49
50
  nil
50
51
  end
51
52
 
52
- def consumer_matches?(info)
53
- cfg = info.config
54
- have = (cfg.respond_to?(:filter_subject) ? cfg.filter_subject : cfg[:filter_subject]).to_s
55
- want = desired_consumer_cfg[:filter_subject].to_s
56
- have == want
53
+ def consumer_matches?(info, want)
54
+ cfg = info.config
55
+ have = {
56
+ filter_subject: val(cfg, :filter_subject),
57
+ ack_policy: val(cfg, :ack_policy),
58
+ deliver_policy: val(cfg, :deliver_policy),
59
+ max_deliver: int_val(cfg, :max_deliver),
60
+ ack_wait: int_val(cfg, :ack_wait),
61
+ backoff: arr_int(cfg, :backoff)
62
+ }
63
+ want_cmp = {
64
+ filter_subject: want[:filter_subject].to_s,
65
+ ack_policy: want[:ack_policy].to_s,
66
+ deliver_policy: want[:deliver_policy].to_s,
67
+ max_deliver: want[:max_deliver].to_i,
68
+ ack_wait: want[:ack_wait].to_i,
69
+ backoff: Array(want[:backoff]).map(&:to_i)
70
+ }
71
+ have == want_cmp
57
72
  end
58
73
 
59
74
  def recreate_consumer!
60
75
  Logging.warn(
61
- "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
76
+ "Consumer #{@durable} exists with mismatched config; " \
77
+ "recreating (filter=#{filter_subject})",
62
78
  tag: 'JetstreamBridge::Consumer'
63
79
  )
64
80
  safe_delete_consumer
@@ -67,26 +83,42 @@ module JetstreamBridge
67
83
 
68
84
  def create_consumer!
69
85
  @jts.add_consumer(stream_name, **desired_consumer_cfg)
70
- Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
71
- tag: 'JetstreamBridge::Consumer')
86
+ Logging.info(
87
+ "Created consumer #{@durable} (filter=#{filter_subject})",
88
+ tag: 'JetstreamBridge::Consumer'
89
+ )
72
90
  end
73
91
 
74
92
  def log_consumer_ok
75
- Logging.info("Consumer #{@durable} exists with desired config.",
76
- tag: 'JetstreamBridge::Consumer')
93
+ Logging.info(
94
+ "Consumer #{@durable} exists with desired config.",
95
+ tag: 'JetstreamBridge::Consumer'
96
+ )
77
97
  end
78
98
 
79
99
  def safe_delete_consumer
80
100
  @jts.delete_consumer(stream_name, @durable)
81
101
  rescue NATS::JetStream::Error => e
82
- Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
83
- tag: 'JetstreamBridge::Consumer')
102
+ Logging.warn(
103
+ "Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
104
+ tag: 'JetstreamBridge::Consumer'
105
+ )
84
106
  end
85
107
 
86
- def consumer_mismatch?(info, desired_cfg)
87
- cfg = info.config
88
- (cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
89
- desired_cfg[:filter_subject].to_s
108
+ # ---- cfg helpers (client may return struct-like or hash-like objects) ----
109
+
110
+ def val(cfg, key)
111
+ (cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key])&.to_s
112
+ end
113
+
114
+ def int_val(cfg, key)
115
+ v = cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
116
+ v.to_i
117
+ end
118
+
119
+ def arr_int(cfg, key)
120
+ v = cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
121
+ Array(v).map(&:to_i)
90
122
  end
91
123
  end
92
124
  end
@@ -48,9 +48,8 @@ module JetstreamBridge
48
48
  establish_connection(servers)
49
49
 
50
50
  Logging.info(
51
- "Connected to NATS (#{servers.size} server#{unless servers.size == 1
52
- 's'
53
- end}): #{sanitize_urls(servers).join(', ')}",
51
+ "Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
52
+ "#{sanitize_urls(servers).join(', ')}",
54
53
  tag: 'JetstreamBridge::Connection'
55
54
  )
56
55
 
@@ -82,14 +81,10 @@ module JetstreamBridge
82
81
  @jts = @nc.jetstream
83
82
 
84
83
  # --- Compatibility shim: ensure JetStream responds to #nc for older/newer clients ---
85
- # Some versions of the NATS Ruby client don't expose nc on the JetStream object.
86
- # We attach a singleton method, so code expecting `js.nc` continues to work.
87
84
  return if @jts.respond_to?(:nc)
88
85
 
89
86
  nc_ref = @nc
90
87
  @jts.define_singleton_method(:nc) { nc_ref }
91
-
92
- # ------------------------------------------------------------------------------------
93
88
  end
94
89
 
95
90
  # Expose for class-level helpers (not part of public API)
@@ -49,9 +49,12 @@ module JetstreamBridge
49
49
 
50
50
  def do_publish?(subject, envelope)
51
51
  headers = { 'nats-msg-id' => envelope['event_id'] }
52
+
52
53
  @jts.publish(subject, JSON.generate(envelope), header: headers)
53
- Logging.info("Published #{subject} event_id=#{envelope['event_id']}",
54
- tag: 'JetstreamBridge::Publisher')
54
+ Logging.info(
55
+ "Published #{subject} event_id=#{envelope['event_id']}",
56
+ tag: 'JetstreamBridge::Publisher'
57
+ )
55
58
  true
56
59
  end
57
60
 
@@ -60,8 +63,10 @@ module JetstreamBridge
60
63
  klass = ModelUtils.constantize(JetstreamBridge.config.outbox_model)
61
64
 
62
65
  unless ModelUtils.ar_class?(klass)
63
- Logging.warn("Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
64
- tag: 'JetstreamBridge::Publisher')
66
+ Logging.warn(
67
+ "Outbox model #{klass} is not an ActiveRecord model; publishing directly.",
68
+ tag: 'JetstreamBridge::Publisher'
69
+ )
65
70
  return with_retries { do_publish?(subject, envelope) }
66
71
  end
67
72
 
@@ -70,8 +75,10 @@ module JetstreamBridge
70
75
  record = repo.find_or_build(event_id)
71
76
 
72
77
  if repo.already_sent?(record)
73
- Logging.info("Outbox already sent event_id=#{event_id}; skipping publish.",
74
- tag: 'JetstreamBridge::Publisher')
78
+ Logging.info(
79
+ "Outbox already sent event_id=#{event_id}; skipping publish.",
80
+ tag: 'JetstreamBridge::Publisher'
81
+ )
75
82
  return true
76
83
  end
77
84
 
@@ -102,14 +109,18 @@ module JetstreamBridge
102
109
 
103
110
  def backoff(attempts, error)
104
111
  delay = RETRY_BACKOFFS[attempts - 1] || RETRY_BACKOFFS.last
105
- Logging.warn("Publish retry #{attempts} after #{error.class}: #{error.message}",
106
- tag: 'JetstreamBridge::Publisher')
112
+ Logging.warn(
113
+ "Publish retry #{attempts} after #{error.class}: #{error.message}",
114
+ tag: 'JetstreamBridge::Publisher'
115
+ )
107
116
  sleep delay
108
117
  end
109
118
 
110
119
  def log_error(val, exc)
111
- Logging.error("Publish failed: #{exc.class} #{exc.message}",
112
- tag: 'JetstreamBridge::Publisher')
120
+ Logging.error(
121
+ "Publish failed: #{exc.class} #{exc.message}",
122
+ tag: 'JetstreamBridge::Publisher'
123
+ )
113
124
  val
114
125
  end
115
126
 
@@ -5,11 +5,82 @@ require_relative 'overlap_guard'
5
5
  require_relative 'subject_matcher'
6
6
 
7
7
  module JetstreamBridge
8
- # Ensures a stream exists and adds only subjects that are not already covered.
8
+ module StreamSupport
9
+ module_function
10
+
11
+ def normalize_subjects(list)
12
+ Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
13
+ end
14
+
15
+ def missing_subjects(existing, desired)
16
+ desired.reject { |d| SubjectMatcher.covered?(existing, d) }
17
+ end
18
+
19
+ def stream_not_found?(error)
20
+ msg = error.message.to_s
21
+ msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
22
+ end
23
+
24
+ def overlap_error?(error)
25
+ msg = error.message.to_s
26
+ msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\b400\b/
27
+ end
28
+
29
+ # ---- Logging ----
30
+ def log_already_covered(name)
31
+ Logging.info(
32
+ "Stream #{name} exists; subjects and config already covered.",
33
+ tag: 'JetstreamBridge::Stream'
34
+ )
35
+ end
36
+
37
+ def log_all_blocked(name, blocked)
38
+ if blocked.any?
39
+ Logging.warn(
40
+ "Stream #{name}: all missing subjects belong to other streams; unchanged. " \
41
+ "blocked=#{blocked.inspect}",
42
+ tag: 'JetstreamBridge::Stream'
43
+ )
44
+ else
45
+ Logging.info("Stream #{name} exists; nothing to add.", tag: 'JetstreamBridge::Stream')
46
+ end
47
+ end
48
+
49
+ def log_updated(name, added, blocked)
50
+ msg = "Updated stream #{name}; added subjects=#{added.inspect}"
51
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
52
+ Logging.info(msg, tag: 'JetstreamBridge::Stream')
53
+ end
54
+
55
+ def log_not_created(name, blocked)
56
+ Logging.warn(
57
+ "Not creating stream #{name}: all desired subjects belong to other streams. " \
58
+ "blocked=#{blocked.inspect}",
59
+ tag: 'JetstreamBridge::Stream'
60
+ )
61
+ end
62
+
63
+ def log_created(name, allowed, blocked, retention, storage)
64
+ msg = [
65
+ "Created stream #{name}",
66
+ "subjects=#{allowed.inspect}",
67
+ "retention=#{retention.inspect}",
68
+ "storage=#{storage.inspect}"
69
+ ].join(' ')
70
+ msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
71
+ Logging.info(msg, tag: 'JetstreamBridge::Stream')
72
+ end
73
+ end
74
+
75
+ # Ensures a stream exists and updates only uncovered subjects, using work-queue semantics:
76
+ # Persist with zero consumers; delete after the first ack (retention: 'workqueue').
9
77
  class Stream
78
+ RETENTION = 'workqueue'
79
+ STORAGE = 'file'
80
+
10
81
  class << self
11
82
  def ensure!(jts, name, subjects)
12
- desired = normalize_subjects(subjects)
83
+ desired = StreamSupport.normalize_subjects(subjects)
13
84
  raise ArgumentError, 'subjects must not be empty' if desired.empty?
14
85
 
15
86
  attempts = 0
@@ -17,14 +88,18 @@ module JetstreamBridge
17
88
  info = safe_stream_info(jts, name)
18
89
  info ? ensure_update(jts, name, info, desired) : ensure_create(jts, name, desired)
19
90
  rescue NATS::JetStream::Error => e
20
- if overlap_error?(e) && (attempts += 1) <= 1
21
- Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'JetstreamBridge::Stream')
91
+ if StreamSupport.overlap_error?(e) && (attempts += 1) <= 1
92
+ Logging.warn(
93
+ "Overlap race while ensuring #{name}; retrying once...",
94
+ tag: 'JetstreamBridge::Stream'
95
+ )
22
96
  sleep(0.05)
23
97
  retry
24
- elsif overlap_error?(e)
98
+ elsif StreamSupport.overlap_error?(e)
25
99
  Logging.warn(
26
100
  "Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
27
- tag: 'JetstreamBridge::Stream')
101
+ tag: 'JetstreamBridge::Stream'
102
+ )
28
103
  nil
29
104
  else
30
105
  raise
@@ -34,95 +109,77 @@ module JetstreamBridge
34
109
 
35
110
  private
36
111
 
37
- # ---------- Update existing stream ----------
38
-
39
- def ensure_update(jts, name, info, desired)
40
- existing = normalize_subjects(info.config.subjects || [])
41
- to_add = missing_subjects(existing, desired)
42
- return log_already_covered(name) if to_add.empty?
43
-
44
- allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
45
- return log_all_blocked(name, blocked) if allowed.empty?
46
-
47
- target = (existing + allowed).uniq
48
- OverlapGuard.check!(jts, name, target)
49
- jts.update_stream(name: name, subjects: target)
50
- log_updated(name, allowed, blocked)
51
- end
112
+ # ---- keep ensure_update small (<=20 lines, lower ABC) ----
113
+ def ensure_update(jts, name, info, desired_subjects)
114
+ existing = StreamSupport.normalize_subjects(info.config.subjects || [])
115
+ to_add = StreamSupport.missing_subjects(existing, desired_subjects)
52
116
 
53
- # ---------- Create new stream ----------
117
+ return add_subjects(jts, name, existing, to_add) if to_add.any?
54
118
 
55
- def ensure_create(jts, name, desired)
56
- allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired)
57
- return log_not_created(name, blocked) if allowed.empty?
119
+ if config_needs_update?(info)
120
+ apply_update(jts, name, existing)
121
+ return log_config_updated(name)
122
+ end
58
123
 
59
- jts.add_stream(name: name, subjects: allowed, retention: 'interest', storage: 'file')
60
- log_created(name, allowed, blocked)
124
+ StreamSupport.log_already_covered(name)
61
125
  end
62
126
 
63
- # ---------- Helpers ----------
64
-
65
- def safe_stream_info(jts, name)
66
- jts.stream_info(name)
67
- rescue NATS::JetStream::Error => e
68
- return nil if stream_not_found?(e)
69
- raise
70
- end
71
-
72
- def missing_subjects(existing, desired)
73
- desired.reject { |d| SubjectMatcher.covered?(existing, d) }
74
- end
127
+ # ---- tiny helpers extracted to reduce ABC ----
128
+ def add_subjects(jts, name, existing, to_add)
129
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
130
+ return StreamSupport.log_all_blocked(name, blocked) if allowed.empty?
75
131
 
76
- def normalize_subjects(list)
77
- Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
132
+ target = merge_subjects(existing, allowed)
133
+ OverlapGuard.check!(jts, name, target)
134
+ apply_update(jts, name, target)
135
+ StreamSupport.log_updated(name, allowed, blocked)
78
136
  end
79
137
 
80
- def stream_not_found?(error)
81
- msg = error.message.to_s
82
- msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
138
+ def merge_subjects(existing, allowed)
139
+ (existing + allowed).uniq
83
140
  end
84
141
 
85
- def overlap_error?(error)
86
- msg = error.message.to_s
87
- msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\bstatus_code=400\b/
142
+ def config_needs_update?(info)
143
+ info.config.retention.to_s.downcase != RETENTION ||
144
+ info.config.storage.to_s.downcase != STORAGE
88
145
  end
89
146
 
90
- # ---------- Logging wrappers ----------
91
-
92
- def log_already_covered(name)
93
- Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
147
+ def apply_update(jts, name, subjects)
148
+ jts.update_stream(
149
+ name: name,
150
+ subjects: subjects,
151
+ retention: RETENTION,
152
+ storage: STORAGE
153
+ )
94
154
  end
95
155
 
96
- def log_all_blocked(name, blocked)
97
- if blocked.any?
98
- Logging.warn(
99
- "Stream #{name}: all missing subjects are owned by other streams; leaving unchanged. " \
100
- "blocked=#{blocked.inspect}",
101
- tag: 'JetstreamBridge::Stream'
102
- )
103
- else
104
- Logging.info("Stream #{name} exists; nothing to add.", tag: 'JetstreamBridge::Stream')
105
- end
156
+ def log_config_updated(name)
157
+ Logging.info(
158
+ "Updated stream #{name} config; retention=#{RETENTION.inspect} " \
159
+ "storage=#{STORAGE.inspect}",
160
+ tag: 'JetstreamBridge::Stream'
161
+ )
106
162
  end
107
163
 
108
- def log_updated(name, added, blocked)
109
- msg = "Updated stream #{name}; added subjects=#{added.inspect}"
110
- msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
111
- Logging.info(msg, tag: 'JetstreamBridge::Stream')
112
- end
164
+ def ensure_create(jts, name, desired_subjects)
165
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired_subjects)
166
+ return StreamSupport.log_not_created(name, blocked) if allowed.empty?
113
167
 
114
- def log_not_created(name, blocked)
115
- Logging.warn(
116
- "Not creating stream #{name}: all desired subjects are owned by other streams. " \
117
- "blocked=#{blocked.inspect}",
118
- tag: 'JetstreamBridge::Stream'
168
+ jts.add_stream(
169
+ name: name,
170
+ subjects: allowed,
171
+ retention: RETENTION,
172
+ storage: STORAGE
119
173
  )
174
+ StreamSupport.log_created(name, allowed, blocked, RETENTION, STORAGE)
120
175
  end
121
176
 
122
- def log_created(name, allowed, blocked)
123
- msg = "Created stream #{name} subjects=#{allowed.inspect}"
124
- msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
125
- Logging.info(msg, tag: 'JetstreamBridge::Stream')
177
+ def safe_stream_info(jts, name)
178
+ jts.stream_info(name)
179
+ rescue NATS::JetStream::Error => e
180
+ return nil if StreamSupport.stream_not_found?(e)
181
+
182
+ raise
126
183
  end
127
184
  end
128
185
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '2.2.1'
7
+ VERSION = '2.3.0'
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: 2.2.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Attara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-20 00:00:00.000000000 Z
11
+ date: 2025-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord