jetstream_bridge 2.2.1 → 2.4.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/lib/jetstream_bridge/consumer/backoff_strategy.rb +24 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +16 -11
- data/lib/jetstream_bridge/consumer/consumer_config.rb +4 -10
- data/lib/jetstream_bridge/consumer/dlq_publisher.rb +53 -0
- data/lib/jetstream_bridge/consumer/message_context.rb +22 -0
- data/lib/jetstream_bridge/consumer/message_processor.rb +76 -30
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +116 -19
- data/lib/jetstream_bridge/core/connection.rb +2 -7
- data/lib/jetstream_bridge/core/duration.rb +82 -20
- 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
- data/lib/jetstream_bridge.rb +1 -1
- metadata +24 -35
- data/.github/workflows/release.yml +0 -150
- data/.gitignore +0 -56
- data/.idea/.gitignore +0 -8
- data/.idea/dictionaries/project.xml +0 -16
- data/.idea/jetstream_bridge.iml +0 -102
- data/.idea/misc.xml +0 -4
- data/.idea/modules.xml +0 -8
- data/.idea/vcs.xml +0 -6
- data/.rubocop.yml +0 -98
- data/Gemfile +0 -5
- data/Gemfile.lock +0 -268
- data/LICENSE +0 -21
- data/README.md +0 -302
- data/jetstream_bridge.gemspec +0 -60
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3609786f5181b8d4af9197952b7ff8d2878123c1c248bd6345454b99b43e0803
|
4
|
+
data.tar.gz: e0f521d0962785ca1c8e3cb271c7961d8aa9c55ac58b441ae04c39d150de4aae
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd6d5b0380a0ff25c60f093c7d64b80bd5733666d7621f07462817112b5fdeb4b6f63396e1be09ce7f5ad4cb9368d185ecd4a8c5674fae3e677def970fd2f1ed
|
7
|
+
data.tar.gz: 7d595ae9091f19e2c35168ed3422a2b5cf424d183f5d4c3942c97b423b8a9f9c70cd560e08c26979ec818929975dc590143e7fa30f5706068d594e4c59ec2598
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JetstreamBridge
|
4
|
+
class BackoffStrategy
|
5
|
+
TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
|
6
|
+
MAX_EXPONENT = 6
|
7
|
+
MAX_DELAY = 60
|
8
|
+
MIN_DELAY = 1
|
9
|
+
|
10
|
+
# Returns a bounded delay in seconds
|
11
|
+
def delay(deliveries, error)
|
12
|
+
base = transient?(error) ? 0.5 : 2.0
|
13
|
+
power = [deliveries - 1, MAX_EXPONENT].min
|
14
|
+
raw = (base * (2**power)).to_i
|
15
|
+
raw.clamp(MIN_DELAY, MAX_DELAY)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def transient?(error)
|
21
|
+
TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -13,23 +13,24 @@ 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
|
20
20
|
IDLE_SLEEP_SECS = 0.05
|
21
21
|
|
22
|
-
def initialize(durable_name
|
22
|
+
def initialize(durable_name: JetstreamBridge.config.durable_name,
|
23
|
+
batch_size: DEFAULT_BATCH_SIZE, &block)
|
23
24
|
@handler = block
|
24
25
|
@batch_size = batch_size
|
25
|
-
@durable = durable_name
|
26
|
+
@durable = durable_name
|
26
27
|
@jts = Connection.connect!
|
27
28
|
|
28
29
|
ensure_destination!
|
29
30
|
|
30
31
|
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
31
32
|
@sub_mgr.ensure_consumer!
|
32
|
-
@psub
|
33
|
+
@psub = @sub_mgr.subscribe!
|
33
34
|
|
34
35
|
@processor = MessageProcessor.new(@jts, @handler)
|
35
36
|
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
@@ -71,24 +72,28 @@ module JetstreamBridge
|
|
71
72
|
msgs.sum { |m| process_one(m) }
|
72
73
|
end
|
73
74
|
|
74
|
-
def process_one(
|
75
|
+
def process_one(msg)
|
75
76
|
if @inbox_proc
|
76
|
-
@inbox_proc.process(
|
77
|
+
@inbox_proc.process(msg) ? 1 : 0
|
77
78
|
else
|
78
|
-
@processor.handle_message(
|
79
|
+
@processor.handle_message(msg)
|
79
80
|
1
|
80
81
|
end
|
81
82
|
end
|
82
83
|
|
83
84
|
def handle_js_error(e)
|
84
85
|
if recoverable_consumer_error?(e)
|
85
|
-
Logging.warn(
|
86
|
-
|
86
|
+
Logging.warn(
|
87
|
+
"Recovering subscription after error: #{e.class} #{e.message}",
|
88
|
+
tag: 'JetstreamBridge::Consumer'
|
89
|
+
)
|
87
90
|
@sub_mgr.ensure_consumer!
|
88
91
|
@psub = @sub_mgr.subscribe!
|
89
92
|
else
|
90
|
-
Logging.error(
|
91
|
-
|
93
|
+
Logging.error(
|
94
|
+
"Fetch failed: #{e.class} #{e.message}",
|
95
|
+
tag: 'JetstreamBridge::Consumer'
|
96
|
+
)
|
92
97
|
end
|
93
98
|
0
|
94
99
|
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
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'time'
|
5
|
+
require_relative '../core/logging'
|
6
|
+
|
7
|
+
module JetstreamBridge
|
8
|
+
class DlqPublisher
|
9
|
+
def initialize(jts)
|
10
|
+
@jts = jts
|
11
|
+
end
|
12
|
+
|
13
|
+
# Sends original payload to DLQ with explanatory headers/context
|
14
|
+
def publish(msg, ctx, reason:, error_class:, error_message:)
|
15
|
+
return unless JetstreamBridge.config.use_dlq
|
16
|
+
|
17
|
+
envelope = build_envelope(ctx, reason, error_class, error_message)
|
18
|
+
headers = build_headers(msg.header, reason, ctx.deliveries, envelope)
|
19
|
+
@jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: headers)
|
20
|
+
rescue StandardError => e
|
21
|
+
Logging.error(
|
22
|
+
"DLQ publish failed event_id=#{ctx.event_id}: #{e.class} #{e.message}",
|
23
|
+
tag: 'JetstreamBridge::Consumer'
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def build_envelope(ctx, reason, error_class, error_message)
|
30
|
+
{
|
31
|
+
event_id: ctx.event_id,
|
32
|
+
reason: reason,
|
33
|
+
error_class: error_class,
|
34
|
+
error_message: error_message,
|
35
|
+
deliveries: ctx.deliveries,
|
36
|
+
original_subject: ctx.subject,
|
37
|
+
sequence: ctx.seq,
|
38
|
+
consumer: ctx.consumer,
|
39
|
+
stream: ctx.stream,
|
40
|
+
published_at: Time.now.utc.iso8601
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_headers(original_headers, reason, deliveries, envelope)
|
45
|
+
headers = (original_headers || {}).dup
|
46
|
+
headers['x-dead-letter'] = 'true'
|
47
|
+
headers['x-dlq-reason'] = reason
|
48
|
+
headers['x-deliveries'] = deliveries.to_s
|
49
|
+
headers['x-dlq-context'] = JSON.generate(envelope)
|
50
|
+
headers
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module JetstreamBridge
|
6
|
+
# Immutable per-message metadata
|
7
|
+
MessageContext = Struct.new(
|
8
|
+
:event_id, :deliveries, :subject, :seq, :consumer, :stream,
|
9
|
+
keyword_init: true
|
10
|
+
) do
|
11
|
+
def self.build(msg)
|
12
|
+
new(
|
13
|
+
event_id: msg.header&.[]('nats-msg-id') || SecureRandom.uuid,
|
14
|
+
deliveries: msg.metadata&.num_delivered.to_i,
|
15
|
+
subject: msg.subject,
|
16
|
+
seq: msg.metadata&.sequence,
|
17
|
+
consumer: msg.metadata&.consumer,
|
18
|
+
stream: msg.metadata&.stream
|
19
|
+
)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -1,65 +1,111 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'json'
|
4
|
-
require 'securerandom'
|
5
4
|
require_relative '../core/logging'
|
5
|
+
require_relative 'message_context'
|
6
|
+
require_relative 'dlq_publisher'
|
7
|
+
require_relative 'backoff_strategy'
|
6
8
|
|
7
9
|
module JetstreamBridge
|
8
|
-
#
|
10
|
+
# Orchestrates parse → handler → ack/nak → DLQ
|
9
11
|
class MessageProcessor
|
10
|
-
|
11
|
-
|
12
|
+
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
13
|
+
|
14
|
+
def initialize(jts, handler, dlq: nil, backoff: nil)
|
15
|
+
@jts = jts
|
12
16
|
@handler = handler
|
17
|
+
@dlq = dlq || DlqPublisher.new(jts)
|
18
|
+
@backoff = backoff || BackoffStrategy.new
|
13
19
|
end
|
14
20
|
|
15
21
|
def handle_message(msg)
|
16
|
-
|
17
|
-
|
18
|
-
event = parse_message(msg, event_id)
|
22
|
+
ctx = MessageContext.build(msg)
|
23
|
+
event = parse_message(msg, ctx)
|
19
24
|
return unless event
|
20
25
|
|
21
|
-
process_event(msg, event,
|
26
|
+
process_event(msg, event, ctx)
|
27
|
+
rescue StandardError => e
|
28
|
+
Logging.error(
|
29
|
+
"Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
|
30
|
+
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
|
31
|
+
tag: 'JetstreamBridge::Consumer'
|
32
|
+
)
|
33
|
+
safe_nak(msg)
|
22
34
|
end
|
23
35
|
|
24
36
|
private
|
25
37
|
|
26
|
-
def parse_message(msg,
|
27
|
-
|
38
|
+
def parse_message(msg, ctx)
|
39
|
+
data = msg.data
|
40
|
+
if defined?(Oj)
|
41
|
+
Oj.load(data, mode: :strict)
|
42
|
+
else
|
43
|
+
JSON.parse(data)
|
44
|
+
end
|
28
45
|
rescue JSON::ParserError => e
|
29
|
-
|
46
|
+
@dlq.publish(msg, ctx,
|
47
|
+
reason: 'malformed_json', error_class: e.class.name, error_message: e.message)
|
30
48
|
msg.ack
|
31
|
-
Logging.warn(
|
32
|
-
|
49
|
+
Logging.warn(
|
50
|
+
"Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
51
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
|
52
|
+
tag: 'JetstreamBridge::Consumer'
|
53
|
+
)
|
33
54
|
nil
|
34
55
|
end
|
35
56
|
|
36
|
-
def process_event(msg, event,
|
37
|
-
@handler.call(event,
|
57
|
+
def process_event(msg, event, ctx)
|
58
|
+
@handler.call(event, ctx.subject, ctx.deliveries)
|
59
|
+
msg.ack
|
60
|
+
Logging.info(
|
61
|
+
"ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
|
62
|
+
tag: 'JetstreamBridge::Consumer'
|
63
|
+
)
|
64
|
+
rescue *UNRECOVERABLE_ERRORS => e
|
65
|
+
@dlq.publish(msg, ctx,
|
66
|
+
reason: 'unrecoverable', error_class: e.class.name, error_message: e.message)
|
38
67
|
msg.ack
|
68
|
+
Logging.warn(
|
69
|
+
"DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
70
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{e.class}: #{e.message}",
|
71
|
+
tag: 'JetstreamBridge::Consumer'
|
72
|
+
)
|
39
73
|
rescue StandardError => e
|
40
|
-
ack_or_nak(msg,
|
74
|
+
ack_or_nak(msg, ctx, e)
|
41
75
|
end
|
42
76
|
|
43
|
-
def ack_or_nak(msg,
|
44
|
-
|
45
|
-
|
77
|
+
def ack_or_nak(msg, ctx, error)
|
78
|
+
max_deliver = JetstreamBridge.config.max_deliver.to_i
|
79
|
+
if ctx.deliveries >= max_deliver
|
80
|
+
@dlq.publish(msg, ctx,
|
81
|
+
reason: 'max_deliver_exceeded', error_class: error.class.name, error_message: error.message)
|
46
82
|
msg.ack
|
47
|
-
Logging.warn(
|
48
|
-
|
83
|
+
Logging.warn(
|
84
|
+
"DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
85
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
|
86
|
+
tag: 'JetstreamBridge::Consumer'
|
87
|
+
)
|
49
88
|
else
|
50
|
-
msg
|
51
|
-
Logging.warn(
|
52
|
-
|
89
|
+
safe_nak(msg, ctx, error)
|
90
|
+
Logging.warn(
|
91
|
+
"NAK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
|
92
|
+
"deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
|
93
|
+
tag: 'JetstreamBridge::Consumer'
|
94
|
+
)
|
53
95
|
end
|
54
96
|
end
|
55
97
|
|
56
|
-
def
|
57
|
-
|
58
|
-
|
59
|
-
|
98
|
+
def safe_nak(msg, ctx = nil, _error = nil)
|
99
|
+
# If your NATS client supports delayed NAKs, uncomment:
|
100
|
+
# delay = @backoff.delay(ctx&.deliveries.to_i, error) if ctx
|
101
|
+
# msg.nak(next_delivery_delay: delay)
|
102
|
+
msg.nak
|
60
103
|
rescue StandardError => e
|
61
|
-
Logging.error(
|
62
|
-
|
104
|
+
Logging.error(
|
105
|
+
"Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
|
106
|
+
"#{e.class} #{e.message}",
|
107
|
+
tag: 'JetstreamBridge::Consumer'
|
108
|
+
)
|
63
109
|
end
|
64
110
|
end
|
65
111
|
end
|
@@ -1,10 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative '../core/logging'
|
4
|
+
require_relative '../core/duration'
|
4
5
|
require_relative '../consumer/consumer_config'
|
5
6
|
|
6
7
|
module JetstreamBridge
|
7
|
-
# Encapsulates durable ensure + subscribe for a consumer.
|
8
|
+
# Encapsulates durable ensure + subscribe for a pull consumer.
|
8
9
|
class SubscriptionManager
|
9
10
|
def initialize(jts, durable, cfg = JetstreamBridge.config)
|
10
11
|
@jts = jts
|
@@ -27,17 +28,23 @@ module JetstreamBridge
|
|
27
28
|
def ensure_consumer!
|
28
29
|
info = consumer_info_or_nil
|
29
30
|
return create_consumer! unless info
|
30
|
-
return log_consumer_ok if consumer_matches?(info)
|
31
31
|
|
32
|
-
|
32
|
+
want = desired_consumer_cfg
|
33
|
+
if consumer_matches?(info, want)
|
34
|
+
log_consumer_ok
|
35
|
+
else
|
36
|
+
log_consumer_diff(info, want)
|
37
|
+
recreate_consumer!
|
38
|
+
end
|
33
39
|
end
|
34
40
|
|
41
|
+
# Bind a pull subscriber to the existing durable.
|
35
42
|
def subscribe!
|
36
43
|
@jts.pull_subscribe(
|
37
44
|
filter_subject,
|
38
45
|
@durable,
|
39
46
|
stream: stream_name,
|
40
|
-
config:
|
47
|
+
config: desired_consumer_cfg
|
41
48
|
)
|
42
49
|
end
|
43
50
|
|
@@ -49,13 +56,44 @@ module JetstreamBridge
|
|
49
56
|
nil
|
50
57
|
end
|
51
58
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
59
|
+
# ---- comparison ----
|
60
|
+
|
61
|
+
def consumer_matches?(info, want)
|
62
|
+
have_norm = normalize_consumer_config(info.config)
|
63
|
+
want_norm = normalize_consumer_config(want)
|
64
|
+
have_norm == want_norm
|
65
|
+
end
|
66
|
+
|
67
|
+
def log_consumer_diff(info, want)
|
68
|
+
have_norm = normalize_consumer_config(info.config)
|
69
|
+
want_norm = normalize_consumer_config(want)
|
70
|
+
|
71
|
+
diffs = {}
|
72
|
+
(have_norm.keys | want_norm.keys).each do |k|
|
73
|
+
diffs[k] = { have: have_norm[k], want: want_norm[k] } unless have_norm[k] == want_norm[k]
|
74
|
+
end
|
75
|
+
|
76
|
+
Logging.warn(
|
77
|
+
"Consumer #{@durable} config mismatch (filter=#{filter_subject}) diff=#{diffs}",
|
78
|
+
tag: 'JetstreamBridge::Consumer'
|
79
|
+
)
|
57
80
|
end
|
58
81
|
|
82
|
+
# Normalize both server-returned config objects and our desired hash
|
83
|
+
# into a common hash with consistent units/types for accurate comparison.
|
84
|
+
def normalize_consumer_config(cfg)
|
85
|
+
{
|
86
|
+
filter_subject: sval(cfg, :filter_subject), # string
|
87
|
+
ack_policy: sval(cfg, :ack_policy), # string
|
88
|
+
deliver_policy: sval(cfg, :deliver_policy), # string
|
89
|
+
max_deliver: ival(cfg, :max_deliver), # integer
|
90
|
+
ack_wait_ms: d_ms(cfg, :ack_wait), # integer ms
|
91
|
+
backoff_ms: darr_ms(cfg, :backoff) # array of integer ms
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
# ---- lifecycle helpers ----
|
96
|
+
|
59
97
|
def recreate_consumer!
|
60
98
|
Logging.warn(
|
61
99
|
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
@@ -67,26 +105,85 @@ module JetstreamBridge
|
|
67
105
|
|
68
106
|
def create_consumer!
|
69
107
|
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
70
|
-
Logging.info(
|
71
|
-
|
108
|
+
Logging.info(
|
109
|
+
"Created consumer #{@durable} (filter=#{filter_subject})",
|
110
|
+
tag: 'JetstreamBridge::Consumer'
|
111
|
+
)
|
72
112
|
end
|
73
113
|
|
74
114
|
def log_consumer_ok
|
75
|
-
Logging.info(
|
76
|
-
|
115
|
+
Logging.info(
|
116
|
+
"Consumer #{@durable} exists with desired config.",
|
117
|
+
tag: 'JetstreamBridge::Consumer'
|
118
|
+
)
|
77
119
|
end
|
78
120
|
|
79
121
|
def safe_delete_consumer
|
80
122
|
@jts.delete_consumer(stream_name, @durable)
|
81
123
|
rescue NATS::JetStream::Error => e
|
82
|
-
Logging.warn(
|
83
|
-
|
124
|
+
Logging.warn(
|
125
|
+
"Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
126
|
+
tag: 'JetstreamBridge::Consumer'
|
127
|
+
)
|
128
|
+
end
|
129
|
+
|
130
|
+
# ---- cfg access/normalization (struct-like or hash-like) ----
|
131
|
+
|
132
|
+
def get(cfg, key)
|
133
|
+
cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
|
134
|
+
end
|
135
|
+
|
136
|
+
def sval(cfg, key)
|
137
|
+
v = get(cfg, key)
|
138
|
+
v = v.to_s if v.is_a?(Symbol)
|
139
|
+
v&.to_s&.downcase
|
140
|
+
end
|
141
|
+
|
142
|
+
def ival(cfg, key)
|
143
|
+
v = get(cfg, key)
|
144
|
+
v.to_i
|
145
|
+
end
|
146
|
+
|
147
|
+
# Normalize duration-like field to **milliseconds** (Integer).
|
148
|
+
# Accepts:
|
149
|
+
# - Strings:"500ms""30s" "2m", "1h", "250us", "100ns"
|
150
|
+
# - Integers/Floats:
|
151
|
+
# * Server may return large integers in **nanoseconds** → detect and convert.
|
152
|
+
# * Otherwise, we delegate to Duration.to_millis (heuristic/explicit).
|
153
|
+
def d_ms(cfg, key)
|
154
|
+
raw = get(cfg, key)
|
155
|
+
duration_to_ms(raw)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Normalize array of durations to integer milliseconds.
|
159
|
+
def darr_ms(cfg, key)
|
160
|
+
raw = get(cfg, key)
|
161
|
+
Array(raw).map { |d| duration_to_ms(d) }
|
84
162
|
end
|
85
163
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
164
|
+
# ---- duration coercion ----
|
165
|
+
|
166
|
+
def duration_to_ms(val)
|
167
|
+
return nil if val.nil?
|
168
|
+
|
169
|
+
case val
|
170
|
+
when Integer
|
171
|
+
# Heuristic: extremely large integers are likely **nanoseconds** from server
|
172
|
+
# (e.g., 30s => 30_000_000_000 ns). Convert ns → ms.
|
173
|
+
return (val / 1_000_000.0).round if val >= 1_000_000_000
|
174
|
+
|
175
|
+
# otherwise rely on Duration’s :auto heuristic (int <1000 => seconds, >=1000 => ms)
|
176
|
+
Duration.to_millis(val, default_unit: :auto)
|
177
|
+
when Float
|
178
|
+
Duration.to_millis(val, default_unit: :auto) # treated as seconds
|
179
|
+
when String
|
180
|
+
# Strings include unit (ns/us/ms/s/m/h/d) handled by Duration
|
181
|
+
Duration.to_millis(val) # default_unit ignored when unit given
|
182
|
+
else
|
183
|
+
return Duration.to_millis(val.to_f, default_unit: :auto) if val.respond_to?(:to_f)
|
184
|
+
|
185
|
+
raise ArgumentError, "invalid duration: #{val.inspect}"
|
186
|
+
end
|
90
187
|
end
|
91
188
|
end
|
92
189
|
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)
|