jetstream_bridge 1.4.0 → 1.5.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: 535eaf5e00d6c27b8f79c3d065128e917c8acddb36a696f7a097c535e28a20e2
4
- data.tar.gz: 0a6b2c99fea1b1f351b95664034ba879307182dbfaf2b67c399a9ff385ad96ab
3
+ metadata.gz: 6e2b2c796fab5fd06b35517459688fa9497130af361455ef16719e4cffcdaef5
4
+ data.tar.gz: 5464863f1c55c4798db4607dfb7cdd9ade28933685d02231ae56cedf2b113312
5
5
  SHA512:
6
- metadata.gz: c0514b7de514f05785ca2208ce4613be7814702141c04bf5be3266b4f7b9e6d0406288d021715779efb23824fe45b5effa92ca4c9fc7f612586de7b97eb3cfe3
7
- data.tar.gz: 9f9cde6bfad36b565a1f7f7aa0ff7b889a4758ff91f86ee663296f85ef676c759de3b1268dd03f7de323bb9561f8530fe25d9c57b784bd2a55138afd833844bf
6
+ metadata.gz: 108cde827d26a8840448163af5cce84c5b9f16d6952ce35b0e228c1f9314caf4a822cf196c760fa6e7bf2fe1e6dafc4f89d893ff927a157d7a8420a230ecae4e
7
+ data.tar.gz: b546a279f3b3fa7cd7481fa4c13c81da37c923262b76ba714a1ef828192ae08f74d5b2e65d13145bbc05795b9dd5c54b067eee4ece161e69e7965ed09c975e65
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jetstream_bridge (1.4.0)
4
+ jetstream_bridge (1.5.0)
5
5
  activerecord (>= 6.0)
6
6
  activesupport (>= 6.0)
7
7
  nats-pure (~> 2.4)
@@ -55,15 +55,44 @@ module JetstreamBridge
55
55
  JetstreamBridge.config.destination_subject
56
56
  end
57
57
 
58
+ def desired_consumer_cfg
59
+ ConsumerConfig.consumer_config(@durable, filter_subject)
60
+ end
61
+
58
62
  def ensure_consumer!
59
- @jts.consumer_info(stream_name, @durable)
60
- Logging.info("Consumer #{@durable} exists.", tag: 'JetstreamBridge::Consumer')
63
+ info = @jts.consumer_info(stream_name, @durable)
64
+ if consumer_mismatch?(info, desired_consumer_cfg)
65
+ Logging.warn(
66
+ "Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
67
+ tag: 'JetstreamBridge::Consumer'
68
+ )
69
+ # Be tolerant if delete fails due to races
70
+ begin
71
+ @jts.delete_consumer(stream_name, @durable)
72
+ rescue NATS::JetStream::Error => e
73
+ Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
74
+ tag: 'JetstreamBridge::Consumer')
75
+ end
76
+ @jts.add_consumer(stream_name, **desired_consumer_cfg)
77
+ Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
78
+ tag: 'JetstreamBridge::Consumer')
79
+ else
80
+ Logging.info("Consumer #{@durable} exists with desired config.",
81
+ tag: 'JetstreamBridge::Consumer')
82
+ end
61
83
  rescue NATS::JetStream::Error
62
- @jts.add_consumer(stream_name, **ConsumerConfig.consumer_config(@durable, filter_subject))
84
+ # Not found -> create fresh
85
+ @jts.add_consumer(stream_name, **desired_consumer_cfg)
63
86
  Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
64
87
  tag: 'JetstreamBridge::Consumer')
65
88
  end
66
89
 
90
+ def consumer_mismatch?(info, desired_cfg)
91
+ cfg = info.config
92
+ (cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
93
+ desired_cfg[:filter_subject].to_s
94
+ end
95
+
67
96
  def subscribe!
68
97
  @psub = @jts.pull_subscribe(
69
98
  filter_subject,
@@ -100,7 +129,8 @@ module JetstreamBridge
100
129
  def recoverable_consumer_error?(error)
101
130
  msg = error.message.to_s
102
131
  msg =~ /consumer.*(not\s+found|deleted)/i ||
103
- msg =~ /no\s+responders/i
132
+ msg =~ /no\s+responders/i ||
133
+ msg =~ /stream.*not\s+found/i
104
134
  end
105
135
  end
106
136
  end
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative 'subject_matcher'
5
+ require_relative 'logging'
4
6
 
5
7
  module JetstreamBridge
6
8
  # Checks for overlapping subjects.
7
9
  class OverlapGuard
8
10
  class << self
11
+ # Raise if any desired subjects conflict with other streams.
9
12
  def check!(jts, target_name, new_subjects)
10
13
  conflicts = overlaps(jts, target_name, new_subjects)
11
14
  return if conflicts.empty?
12
15
  raise conflict_message(target_name, conflicts)
13
16
  end
14
17
 
18
+ # Return a list of conflicts against other streams, per subject.
19
+ # [{ name:'OTHER' pairs: [['a.b.*', 'a.b.c'], ...] }, ...]
15
20
  def overlaps(jts, target_name, new_subjects)
16
21
  desired = Array(new_subjects).map!(&:to_s).uniq
17
22
  streams = list_streams_with_subjects(jts)
@@ -26,6 +31,19 @@ module JetstreamBridge
26
31
  end.compact
27
32
  end
28
33
 
34
+ # Returns [allowed, blocked] given desired subjects.
35
+ def partition_allowed(jts, target_name, desired_subjects)
36
+ desired = Array(desired_subjects).map!(&:to_s).uniq
37
+ conflicts = overlaps(jts, target_name, desired)
38
+ blocked = conflicts.flat_map { |c| c[:pairs].map(&:first) }.uniq
39
+ allowed = desired - blocked
40
+ [allowed, blocked]
41
+ end
42
+
43
+ def allowed_subjects(jts, target_name, desired_subjects)
44
+ partition_allowed(jts, target_name, desired_subjects).first
45
+ end
46
+
29
47
  def list_streams_with_subjects(jts)
30
48
  list_stream_names(jts).map do |name|
31
49
  info = jts.stream_info(name)
@@ -47,7 +47,7 @@ module JetstreamBridge
47
47
  true
48
48
  end
49
49
 
50
- # Retry only on transient NATS errors
50
+ # Retry only on transient NATS IO errors
51
51
  def with_retries(retries = DEFAULT_RETRIES)
52
52
  attempts = 0
53
53
  begin
@@ -12,34 +12,82 @@ module JetstreamBridge
12
12
  desired = normalize_subjects(subjects)
13
13
  raise ArgumentError, 'subjects must not be empty' if desired.empty?
14
14
 
15
+ attempts = 0
16
+
15
17
  begin
16
18
  info = jts.stream_info(name)
17
19
  existing = normalize_subjects(info.config.subjects || [])
18
20
 
19
21
  # Skip anything already COVERED by existing patterns (not just exact match)
20
- missing = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
21
- if missing.empty?
22
+ to_add = desired.reject { |d| SubjectMatcher.covered?(existing, d) }
23
+ if to_add.empty?
22
24
  Logging.info("Stream #{name} exists; subjects already covered.", tag: 'JetstreamBridge::Stream')
23
25
  return
24
26
  end
25
27
 
26
- # Validate full target set against other streams
27
- target = (existing + missing).uniq
28
- OverlapGuard.check!(jts, name, target)
28
+ # Filter out subjects owned by other streams to prevent overlap BadRequest
29
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
30
+
31
+ if allowed.empty?
32
+ if blocked.any?
33
+ Logging.warn(
34
+ "Stream #{name}: all missing subjects are owned by other streams; leaving unchanged. " \
35
+ "blocked=#{blocked.inspect}",
36
+ tag: 'JetstreamBridge::Stream'
37
+ )
38
+ else
39
+ Logging.info("Stream #{name} exists; nothing to add.", tag: 'JetstreamBridge::Stream')
40
+ end
41
+ return
42
+ end
29
43
 
30
- # Try to update; handle late overlaps/races
44
+ target = (existing + allowed).uniq
45
+
46
+ # Validate and update (race may still occur; handled in rescue)
47
+ OverlapGuard.check!(jts, name, target)
31
48
  jts.update_stream(name: name, subjects: target)
32
- Logging.info("Updated stream #{name}; added subjects=#{missing.inspect}", tag: 'JetstreamBridge::Stream')
49
+
50
+ Logging.info(
51
+ "Updated stream #{name}; added subjects=#{allowed.inspect}" \
52
+ "#{blocked.any? ? " (skipped overlapped=#{blocked.inspect})" : ''}",
53
+ tag: 'JetstreamBridge::Stream'
54
+ )
33
55
  rescue NATS::JetStream::Error => e
34
56
  if stream_not_found?(e)
35
- # Race: created elsewhere or genuinely missing — create fresh
36
- OverlapGuard.check!(jts, name, desired)
37
- jts.add_stream(name: name, subjects: desired, retention: 'interest', storage: 'file')
38
- Logging.info("Created stream #{name} subjects=#{desired.inspect}", tag: 'JetstreamBridge::Stream')
57
+ # Creating fresh: still filter to avoid BadRequest
58
+ allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired)
59
+ if allowed.empty?
60
+ Logging.warn(
61
+ "Not creating stream #{name}: all desired subjects are owned by other streams. " \
62
+ "blocked=#{blocked.inspect}",
63
+ tag: 'JetstreamBridge::Stream'
64
+ )
65
+ return
66
+ end
67
+
68
+ jts.add_stream(
69
+ name: name,
70
+ subjects: allowed,
71
+ retention: 'interest',
72
+ storage: 'file'
73
+ )
74
+ Logging.info(
75
+ "Created stream #{name} subjects=#{allowed.inspect}" \
76
+ "#{blocked.any? ? " (skipped overlapped=#{blocked.inspect})" : ''}",
77
+ tag: 'JetstreamBridge::Stream'
78
+ )
79
+ elsif overlap_error?(e) && (attempts += 1) <= 1
80
+ # Late race: re-fetch and try once more
81
+ Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'JetstreamBridge::Stream')
82
+ sleep(0.05)
83
+ retry
39
84
  elsif overlap_error?(e)
40
- # Late overlap due to concurrent change recompute and raise with details
41
- conflicts = OverlapGuard.overlaps(jts, name, desired)
42
- raise OverlapGuard.conflict_message(name, conflicts)
85
+ # Give up gracefully (don’t raise)someone else now owns a conflicting subject
86
+ Logging.warn(
87
+ "Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
88
+ tag: 'JetstreamBridge::Stream'
89
+ )
90
+ return
43
91
  else
44
92
  raise
45
93
  end
@@ -11,7 +11,7 @@ module JetstreamBridge
11
11
 
12
12
  # Proper NATS semantics:
13
13
  # - '*' matches exactly one token
14
- # - '>' matches the rest (zero or more tokens) but ONLY after the fixed prefix matches
14
+ # - '>' matches the rest (zero or more tokens)
15
15
  def match?(pattern, subject)
16
16
  p = pattern.split('.')
17
17
  s = subject.split('.')
@@ -30,9 +30,10 @@ module JetstreamBridge
30
30
  i += 1
31
31
  end
32
32
 
33
- # If the pattern still has tokens, it only matches if the remainder is a '>' (or contains one)
33
+ # Exact match
34
34
  return true if i == p.length && i == s.length
35
35
 
36
+ # If pattern has remaining '>' it can absorb remainder
36
37
  p[i] == '>' || p[i..-1]&.include?('>')
37
38
  end
38
39
 
@@ -47,8 +48,8 @@ module JetstreamBridge
47
48
  while ai < a_parts.length && bi < b_parts.length
48
49
  at = a_parts[ai]
49
50
  bt = b_parts[bi]
50
- return true if at == '>' || bt == '>' # either can absorb the rest
51
- return false unless at == bt || at == '*' || bt == '*' # fixed tokens differ
51
+ return true if at == '>' || bt == '>'
52
+ return false unless at == bt || at == '*' || bt == '*'
52
53
  ai += 1
53
54
  bi += 1
54
55
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
  # Version constant for the gem.
6
6
  module JetstreamBridge
7
- VERSION = '1.4.0'
7
+ VERSION = '1.5.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: 1.4.0
4
+ version: 1.5.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-18 00:00:00.000000000 Z
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord