jetstream_bridge 2.3.0 → 2.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/lib/jetstream_bridge/consumer/backoff_strategy.rb +24 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +3 -2
- 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 +63 -25
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +94 -29
- data/lib/jetstream_bridge/core/duration.rb +82 -20
- data/lib/jetstream_bridge/topology/stream.rb +42 -44
- 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 -17
- 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: 0defcef0e3c705c2a62268116e62d93076b4f5ba405de6fcfc1b3ea2b0122101
|
4
|
+
data.tar.gz: 3eaede87e4904d755a8370042f4405011a5dd3996704a21f8f4329676a81dffd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 58b9210d52525cc66332c68ba3c22f03b320941178635a99e489aaf62ad74a90e2bccaaab0e9851aa23397247bb0bdf4c66e2da86924249823039af66a428efd
|
7
|
+
data.tar.gz: d96886372c8067e1147b879f794a772df9052d12966ce20e585ee19d950876c952f76af88400b46da186a35583314e09e0b3f84e9598843696e38a8a3e96a3d5
|
@@ -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
|
@@ -19,10 +19,11 @@ module JetstreamBridge
|
|
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!
|
@@ -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,71 +1,109 @@
|
|
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
|
-
|
12
|
+
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
13
|
+
|
14
|
+
def initialize(jts, handler, dlq: nil, backoff: nil)
|
11
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
49
|
Logging.warn(
|
32
|
-
"Malformed JSON
|
50
|
+
"Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
51
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{e.message}",
|
33
52
|
tag: 'JetstreamBridge::Consumer'
|
34
53
|
)
|
35
54
|
nil
|
36
55
|
end
|
37
56
|
|
38
|
-
def process_event(msg, event,
|
39
|
-
@handler.call(event,
|
57
|
+
def process_event(msg, event, ctx)
|
58
|
+
@handler.call(event, ctx.subject, ctx.deliveries)
|
40
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)
|
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
|
+
)
|
41
73
|
rescue StandardError => e
|
42
|
-
ack_or_nak(msg,
|
74
|
+
ack_or_nak(msg, ctx, e)
|
43
75
|
end
|
44
76
|
|
45
|
-
def ack_or_nak(msg,
|
46
|
-
|
47
|
-
|
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)
|
48
82
|
msg.ack
|
49
83
|
Logging.warn(
|
50
|
-
"
|
84
|
+
"DLQ (max_deliver) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
85
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
|
51
86
|
tag: 'JetstreamBridge::Consumer'
|
52
87
|
)
|
53
88
|
else
|
54
|
-
msg
|
89
|
+
safe_nak(msg, ctx, error)
|
55
90
|
Logging.warn(
|
56
|
-
"NAK
|
91
|
+
"NAK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
|
92
|
+
"deliveries=#{ctx.deliveries} err=#{error.class}: #{error.message}",
|
57
93
|
tag: 'JetstreamBridge::Consumer'
|
58
94
|
)
|
59
95
|
end
|
60
96
|
end
|
61
97
|
|
62
|
-
def
|
63
|
-
|
64
|
-
|
65
|
-
|
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
|
66
103
|
rescue StandardError => e
|
67
104
|
Logging.error(
|
68
|
-
"
|
105
|
+
"Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
|
106
|
+
"#{e.class} #{e.message}",
|
69
107
|
tag: 'JetstreamBridge::Consumer'
|
70
108
|
)
|
71
109
|
end
|
@@ -1,6 +1,7 @@
|
|
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
|
@@ -27,9 +28,14 @@ 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, desired_consumer_cfg)
|
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
|
|
35
41
|
# Bind a pull subscriber to the existing durable.
|
@@ -50,31 +56,47 @@ module JetstreamBridge
|
|
50
56
|
nil
|
51
57
|
end
|
52
58
|
|
59
|
+
# ---- comparison ----
|
60
|
+
|
53
61
|
def consumer_matches?(info, want)
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
+
)
|
80
|
+
end
|
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
|
70
92
|
}
|
71
|
-
have == want_cmp
|
72
93
|
end
|
73
94
|
|
95
|
+
# ---- lifecycle helpers ----
|
96
|
+
|
74
97
|
def recreate_consumer!
|
75
98
|
Logging.warn(
|
76
|
-
"Consumer #{@durable} exists with mismatched config; "
|
77
|
-
"recreating (filter=#{filter_subject})",
|
99
|
+
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
78
100
|
tag: 'JetstreamBridge::Consumer'
|
79
101
|
)
|
80
102
|
safe_delete_consumer
|
@@ -105,20 +127,63 @@ module JetstreamBridge
|
|
105
127
|
)
|
106
128
|
end
|
107
129
|
|
108
|
-
# ---- cfg
|
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
|
109
135
|
|
110
|
-
def
|
111
|
-
|
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
|
112
140
|
end
|
113
141
|
|
114
|
-
def
|
115
|
-
v = cfg
|
142
|
+
def ival(cfg, key)
|
143
|
+
v = get(cfg, key)
|
116
144
|
v.to_i
|
117
145
|
end
|
118
146
|
|
119
|
-
|
120
|
-
|
121
|
-
|
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) }
|
162
|
+
end
|
163
|
+
|
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
|
122
187
|
end
|
123
188
|
end
|
124
189
|
end
|
@@ -4,43 +4,105 @@
|
|
4
4
|
#
|
5
5
|
module JetstreamBridge
|
6
6
|
# Utility for parsing human-friendly durations into milliseconds.
|
7
|
+
#
|
8
|
+
# Defaults to an :auto heuristic for Integer/Float values to preserve
|
9
|
+
# backward compatibility:
|
10
|
+
# - Integers < 1000 are treated as seconds (e.g., 30 -> 30_000ms)
|
11
|
+
# - Integers >= 1000 are treated as milliseconds (e.g., 1500 -> 1500ms)
|
12
|
+
# Prefer setting `default_unit:` to :s or :ms for unambiguous behavior.
|
13
|
+
#
|
7
14
|
# Examples:
|
8
|
-
# Duration.to_millis(30)
|
9
|
-
# Duration.to_millis(
|
10
|
-
# Duration.to_millis(
|
11
|
-
# Duration.to_millis(
|
15
|
+
# Duration.to_millis(30) #=> 30000 (auto)
|
16
|
+
# Duration.to_millis(1500) #=> 1500 (auto)
|
17
|
+
# Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
|
18
|
+
# Duration.to_millis("30s") #=> 30000
|
19
|
+
# Duration.to_millis("500ms") #=> 500
|
20
|
+
# Duration.to_millis("250us") #=> 0
|
21
|
+
# Duration.to_millis("1h") #=> 3_600_000
|
22
|
+
# Duration.to_millis(1_500_000_000, default_unit: :ns) #=> 1500
|
23
|
+
#
|
24
|
+
# Also:
|
25
|
+
# Duration.normalize_list_to_millis(%w[1s 5s 15s]) #=> [1000, 5000, 15000]
|
12
26
|
module Duration
|
13
|
-
|
14
|
-
|
15
|
-
|
27
|
+
# multipliers to convert 1 unit into milliseconds
|
28
|
+
MULTIPLIER_MS = {
|
29
|
+
'ns' => 1.0e-6, # nanoseconds to ms
|
30
|
+
'us' => 1.0e-3, # microseconds to ms
|
31
|
+
'µs' => 1.0e-3, # alt microseconds symbol
|
32
|
+
'ms' => 1, # milliseconds to ms
|
33
|
+
's' => 1_000, # seconds to ms
|
34
|
+
'm' => 60_000, # minutes to ms
|
35
|
+
'h' => 3_600_000, # hours to ms
|
36
|
+
'd' => 86_400_000 # days to ms
|
37
|
+
}.freeze
|
38
|
+
|
39
|
+
NUMBER_RE = /\A\d[\d_]*\z/.freeze
|
40
|
+
TOKEN_RE = /\A(\d[\d_]*(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h|d)\z/i.freeze
|
16
41
|
|
17
42
|
module_function
|
18
43
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
44
|
+
# default_unit:
|
45
|
+
# :auto (heuristic: int<1000 -> seconds, >=1000 -> ms)
|
46
|
+
# :ns, :us, :ms, :s, :m, :h, :d (explicit)
|
47
|
+
def to_millis(val, default_unit: :auto)
|
48
|
+
case val
|
49
|
+
when Integer then int_to_ms(val, default_unit: default_unit)
|
50
|
+
when Float then float_to_ms(val, default_unit: default_unit)
|
51
|
+
when String then string_to_ms(val, default_unit: default_unit == :auto ? :s : default_unit)
|
52
|
+
else
|
53
|
+
raise ArgumentError, "invalid duration type: #{val.class}" unless val.respond_to?(:to_f)
|
54
|
+
|
55
|
+
float_to_ms(val.to_f, default_unit: default_unit)
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
24
59
|
|
25
|
-
|
60
|
+
# Normalize an array of durations into integer milliseconds.
|
61
|
+
def normalize_list_to_millis(values, default_unit: :auto)
|
62
|
+
Array(values).map { |v| to_millis(v, default_unit: default_unit) }
|
26
63
|
end
|
27
64
|
|
28
|
-
|
29
|
-
|
65
|
+
# --- internal helpers ---
|
66
|
+
|
67
|
+
def int_to_ms(num, default_unit:)
|
68
|
+
case default_unit
|
69
|
+
when :auto
|
70
|
+
# Preserve existing heuristic for compatibility
|
71
|
+
i >= 1_000 ? num : num * 1_000
|
72
|
+
else
|
73
|
+
coerce_numeric_to_ms(i.to_f, default_unit)
|
74
|
+
end
|
30
75
|
end
|
31
76
|
|
32
|
-
def float_to_ms(
|
33
|
-
(
|
77
|
+
def float_to_ms(flt, default_unit:)
|
78
|
+
coerce_numeric_to_ms(flt, default_unit)
|
34
79
|
end
|
35
80
|
|
36
|
-
def string_to_ms(str)
|
81
|
+
def string_to_ms(str, default_unit:)
|
37
82
|
s = str.strip
|
38
|
-
|
83
|
+
# Plain number string => use default_unit explicitly (not heuristic)
|
84
|
+
return coerce_numeric_to_ms(s.delete('_').to_f, default_unit) if NUMBER_RE.match?(s)
|
39
85
|
|
40
86
|
m = TOKEN_RE.match(s)
|
41
87
|
raise ArgumentError, "invalid duration: #{str.inspect}" unless m
|
42
88
|
|
43
|
-
|
89
|
+
num = m[1].delete('_').to_f
|
90
|
+
unit = m[2].downcase
|
91
|
+
(num * MULTIPLIER_MS.fetch(unit)).round
|
92
|
+
end
|
93
|
+
|
94
|
+
def coerce_numeric_to_ms(num, unit)
|
95
|
+
case unit
|
96
|
+
when :auto
|
97
|
+
# For floats, :auto treats as seconds (common developer intent)
|
98
|
+
(num * 1_000).round
|
99
|
+
else
|
100
|
+
u = unit.to_s
|
101
|
+
mult = MULTIPLIER_MS[u]
|
102
|
+
raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
|
103
|
+
|
104
|
+
(num * mult).round
|
105
|
+
end
|
44
106
|
end
|
45
107
|
end
|
46
108
|
end
|