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 +4 -4
- data/Gemfile.lock +1 -1
- data/lib/jetstream_bridge/consumer.rb +34 -4
- data/lib/jetstream_bridge/overlap_guard.rb +18 -0
- data/lib/jetstream_bridge/publisher.rb +1 -1
- data/lib/jetstream_bridge/stream.rb +62 -14
- data/lib/jetstream_bridge/subject_matcher.rb +5 -4
- data/lib/jetstream_bridge/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6e2b2c796fab5fd06b35517459688fa9497130af361455ef16719e4cffcdaef5
|
4
|
+
data.tar.gz: 5464863f1c55c4798db4607dfb7cdd9ade28933685d02231ae56cedf2b113312
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 108cde827d26a8840448163af5cce84c5b9f16d6952ce35b0e228c1f9314caf4a822cf196c760fa6e7bf2fe1e6dafc4f89d893ff927a157d7a8420a230ecae4e
|
7
|
+
data.tar.gz: b546a279f3b3fa7cd7481fa4c13c81da37c923262b76ba714a1ef828192ae08f74d5b2e65d13145bbc05795b9dd5c54b067eee4ece161e69e7965ed09c975e65
|
data/Gemfile.lock
CHANGED
@@ -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
|
-
|
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
|
-
|
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)
|
@@ -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
|
-
|
21
|
-
if
|
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
|
-
#
|
27
|
-
|
28
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
36
|
-
OverlapGuard.
|
37
|
-
|
38
|
-
|
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
|
-
#
|
41
|
-
|
42
|
-
|
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)
|
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
|
-
#
|
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 == '>'
|
51
|
-
return false unless at == bt || at == '*' || bt == '*'
|
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
|
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
|
+
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-
|
11
|
+
date: 2025-08-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|