jetstream_bridge 2.2.0 → 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 +4 -4
- data/.idea/dictionaries/project.xml +1 -0
- data/Gemfile.lock +1 -1
- data/README.md +2 -2
- data/lib/jetstream_bridge/consumer/consumer.rb +13 -9
- data/lib/jetstream_bridge/consumer/consumer_config.rb +4 -10
- data/lib/jetstream_bridge/consumer/message_processor.rb +22 -14
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -19
- data/lib/jetstream_bridge/core/connection.rb +2 -7
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +1 -1
- data/lib/jetstream_bridge/publisher/publisher.rb +21 -10
- data/lib/jetstream_bridge/topology/stream.rb +132 -75
- 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: e0501a83858dfa54be16d6e9b9484564f3fc8e275f30473322e3f304501c2ca5
|
4
|
+
data.tar.gz: 6ea1b6fc4333c3e3e4cffb220669877c7271c4c54cab532a22f89e9f41ed5a51
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a6d3749ffda0dc178218ae0e7a55ca45a93d129961fb127958699ef9fa88115814aa19954f1a38a81848de7ca678e86fbb1ba802efa00c9388da3ec444dce1e7
|
7
|
+
data.tar.gz: 3866b27b03ae500859f23cdc39bb6cd72b6e49eeefc8e0bf2e921a14097423e6439baf1ff8b45b08327331cbe387aabff3807fb139fb6f84614bd3b7b35894de
|
data/Gemfile.lock
CHANGED
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.
|
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 `
|
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
|
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
|
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(
|
74
|
+
def process_one(msg)
|
75
75
|
if @inbox_proc
|
76
|
-
@inbox_proc.process(
|
76
|
+
@inbox_proc.process(msg) ? 1 : 0
|
77
77
|
else
|
78
|
-
@processor.handle_message(
|
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(
|
86
|
-
|
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(
|
91
|
-
|
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)
|
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
|
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&.[]('
|
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(
|
32
|
-
|
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,
|
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,
|
42
|
+
ack_or_nak(msg, deliveries, e)
|
41
43
|
end
|
42
44
|
|
43
|
-
def ack_or_nak(msg, deliveries,
|
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(
|
48
|
-
|
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(
|
52
|
-
|
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(
|
62
|
-
|
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:
|
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
|
54
|
-
have =
|
55
|
-
|
56
|
-
|
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;
|
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(
|
71
|
-
|
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(
|
76
|
-
|
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(
|
83
|
-
|
102
|
+
Logging.warn(
|
103
|
+
"Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
104
|
+
tag: 'JetstreamBridge::Consumer'
|
105
|
+
)
|
84
106
|
end
|
85
107
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
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
|
-
|
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)
|
@@ -30,7 +30,7 @@ module JetstreamBridge
|
|
30
30
|
event_id: event_id,
|
31
31
|
subject: subject,
|
32
32
|
payload: ModelUtils.json_dump(envelope),
|
33
|
-
headers: ModelUtils.json_dump({ '
|
33
|
+
headers: ModelUtils.json_dump({ 'nats-msg-id' => event_id }),
|
34
34
|
status: 'publishing',
|
35
35
|
last_error: nil
|
36
36
|
}
|
@@ -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(
|
54
|
-
|
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(
|
64
|
-
|
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(
|
74
|
-
|
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(
|
106
|
-
|
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(
|
112
|
-
|
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
|
-
|
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(
|
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
|
-
#
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
-
|
117
|
+
return add_subjects(jts, name, existing, to_add) if to_add.any?
|
54
118
|
|
55
|
-
|
56
|
-
|
57
|
-
|
119
|
+
if config_needs_update?(info)
|
120
|
+
apply_update(jts, name, existing)
|
121
|
+
return log_config_updated(name)
|
122
|
+
end
|
58
123
|
|
59
|
-
|
60
|
-
log_created(name, allowed, blocked)
|
124
|
+
StreamSupport.log_already_covered(name)
|
61
125
|
end
|
62
126
|
|
63
|
-
#
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
-
|
77
|
-
|
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
|
81
|
-
|
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
|
86
|
-
|
87
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
109
|
-
|
110
|
-
|
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
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
123
|
-
|
124
|
-
|
125
|
-
|
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
|
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.
|
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-
|
11
|
+
date: 2025-08-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|