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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0501a83858dfa54be16d6e9b9484564f3fc8e275f30473322e3f304501c2ca5
4
- data.tar.gz: 6ea1b6fc4333c3e3e4cffb220669877c7271c4c54cab532a22f89e9f41ed5a51
3
+ metadata.gz: 0defcef0e3c705c2a62268116e62d93076b4f5ba405de6fcfc1b3ea2b0122101
4
+ data.tar.gz: 3eaede87e4904d755a8370042f4405011a5dd3996704a21f8f4329676a81dffd
5
5
  SHA512:
6
- metadata.gz: a6d3749ffda0dc178218ae0e7a55ca45a93d129961fb127958699ef9fa88115814aa19954f1a38a81848de7ca678e86fbb1ba802efa00c9388da3ec444dce1e7
7
- data.tar.gz: 3866b27b03ae500859f23cdc39bb6cd72b6e49eeefc8e0bf2e921a14097423e6439baf1ff8b45b08327331cbe387aabff3807fb139fb6f84614bd3b7b35894de
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:, batch_size: DEFAULT_BATCH_SIZE, &block)
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 || JetstreamBridge.config.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
- # Handles parse → handler → ack / nak → DLQ.
10
+ # Orchestrates parse → handler → ack/nak → DLQ
9
11
  class MessageProcessor
10
- def initialize(jts, handler)
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
- deliveries = msg.metadata&.num_delivered.to_i
17
- event_id = msg.header&.[]('nats-msg-id') || SecureRandom.uuid
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, deliveries, event_id)
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, event_id)
27
- JSON.parse(msg.data)
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
- publish_to_dlq!(msg)
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 to DLQ event_id=#{event_id}: #{e.message}",
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, deliveries, _event_id)
39
- @handler.call(event, msg.subject, deliveries)
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, deliveries, e)
74
+ ack_or_nak(msg, ctx, e)
43
75
  end
44
76
 
45
- def ack_or_nak(msg, deliveries, error)
46
- if deliveries >= JetstreamBridge.config.max_deliver.to_i
47
- publish_to_dlq!(msg)
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
- "Sent to DLQ after max_deliver err=#{error.message}",
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.nak
89
+ safe_nak(msg, ctx, error)
55
90
  Logging.warn(
56
- "NAK deliveries=#{deliveries} err=#{error.message}",
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 publish_to_dlq!(msg)
63
- return unless JetstreamBridge.config.use_dlq
64
-
65
- @jts.publish(JetstreamBridge.config.dlq_subject, msg.data, header: msg.header)
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
- "DLQ publish failed: #{e.class} #{e.message}",
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
- recreate_consumer!
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
- 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)
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 helpers (client may return struct-like or hash-like objects) ----
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 val(cfg, key)
111
- (cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key])&.to_s
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 int_val(cfg, key)
115
- v = cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
142
+ def ival(cfg, key)
143
+ v = get(cfg, key)
116
144
  v.to_i
117
145
  end
118
146
 
119
- def arr_int(cfg, key)
120
- v = cfg.respond_to?(key) ? cfg.public_send(key) : cfg[key]
121
- Array(v).map(&:to_i)
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) #=> 30000
9
- # Duration.to_millis("30s") #=> 30000
10
- # Duration.to_millis("500ms") #=> 500
11
- # Duration.to_millis(0.5) #=> 500
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
- MULTIPLIER = { 'ms' => 1, 's' => 1_000, 'm' => 60_000, 'h' => 3_600_000 }.freeze
14
- NUMBER_RE = /\A\d+\z/.freeze
15
- TOKEN_RE = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h)\z/i.freeze
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
- def to_millis(val)
20
- return int_to_ms(val) if val.is_a?(Integer)
21
- return float_to_ms(val) if val.is_a?(Float)
22
- return string_to_ms(val) if val.is_a?(String)
23
- return float_to_ms(val.to_f) if val.respond_to?(:to_f)
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
- raise ArgumentError, "invalid duration type: #{val.class}"
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
- def int_to_ms(i)
29
- i >= 1_000 ? i : i * 1_000
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(f)
33
- (f * 1_000).round
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
- return int_to_ms(s.to_i) if NUMBER_RE.match?(s)
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
- (m[1].to_f * MULTIPLIER.fetch(m[2].downcase)).round
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