cosmonats 0.5.1 → 0.6.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: 4ade68e6438fc5ea6cec3fd97bf2794c87f2117414c56c69e49e67544f35e7ab
4
- data.tar.gz: db98a44b41d057795b6a9f8c56316252b4cad1ab2f2a07912a66e16c09a86013
3
+ metadata.gz: 1b9d8091f782c96278e0de951486f14f3207f8af37d99f5460d122ee61ab0616
4
+ data.tar.gz: 0e76adacb61e924afd50d127a0c5259c9c454827e75276b44fba0ba7879409a0
5
5
  SHA512:
6
- metadata.gz: bd359b85f69bcdc85f800ec8dc0e4b24ff09e905851e412047dd96a0154c21b459bf52465493ee1a05c664968817828124fa35919cd7174d0f3c0ebe0ba867ca
7
- data.tar.gz: dbb223d897a14e457bcf78085896ed0c7f1548eb7565621e35b62257879065dfb653d8cc58a396c921d508afe92e1b9640cbfd3cf9845d00ee471b577609ffa7
6
+ metadata.gz: 0277a84383b59f6c5fe7502a94f51619778aef691877dad39cf093733fbc69b8a2f4ac676006f4e67bf60db84af03ab49b741d38662652ce4fb017e1aef5f699
7
+ data.tar.gz: 2c850062acd3f24925e0136ef95ff7ea8c24a279f9ae7ad04526baea01524e46dcd266e8cc996d918a091fc39335d8283000dab56daffc46758e60ef17ea460b
data/README.md CHANGED
@@ -364,7 +364,7 @@ consumers:
364
364
  priority: 5
365
365
  scheduled:
366
366
  <<: *consumer_config
367
- max_deliver: 1
367
+ max_deliver: 5
368
368
  max_ack_pending: 100
369
369
  ack_wait: 10
370
370
 
data/lib/cosmo/client.rb CHANGED
@@ -22,8 +22,13 @@ module Cosmo
22
22
  js.publish(subject, payload, **params)
23
23
  end
24
24
 
25
+ # Create a pull subscription. Durable with +consumer_name+, and ephemeral without.
26
+ # @param config [Hash] Consumer config. Ephemeral consumers additionally require
27
+ # +:stream+ and +:inactive_threshold+ (seconds the consumer survives without a fetch).
25
28
  def subscribe(subject, consumer_name, config)
26
- js.pull_subscribe(subject, consumer_name, config: config)
29
+ return js.pull_subscribe(subject, consumer_name, config: config) if consumer_name
30
+
31
+ ephemeral_subscribe(subject, config)
27
32
  end
28
33
 
29
34
  def stream_info(name)
@@ -103,6 +108,10 @@ module Cosmo
103
108
  js.consumer_info(stream_name, consumer_name)
104
109
  end
105
110
 
111
+ def delete_consumer(stream_name, consumer_name)
112
+ js.delete_consumer(stream_name, consumer_name)
113
+ end
114
+
106
115
  def get_message(stream_name, **options)
107
116
  js.get_msg(stream_name, **options)
108
117
  end
@@ -133,6 +142,32 @@ module Cosmo
133
142
 
134
143
  private
135
144
 
145
+ # NOTE: nats-pure's #pull_subscribe has no path to a true ephemeral pull consumer.
146
+ # Its rescue branch unconditionally sets `config[:durable_name] = durable` (jetstream.rb).
147
+ def ephemeral_subscribe(subject, config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
148
+ config = config.dup
149
+ stream = config.delete(:stream) or raise ArgumentError, "stream required for ephemeral consumers"
150
+ raise ArgumentError, "inactive_threshold required for ephemeral consumers" unless config[:inactive_threshold]
151
+
152
+ subject = subject.first if subject.is_a?(Array) && subject.size == 1
153
+ consumer_config = NATS::JetStream::API::ConsumerConfig.new(config)
154
+ if subject.is_a?(Array)
155
+ consumer_config[:filter_subjects] ||= subject
156
+ else
157
+ consumer_config[:filter_subject] ||= subject
158
+ end
159
+
160
+ info = js.add_consumer(stream, consumer_config)
161
+
162
+ sub = nc.subscribe(nc.new_inbox)
163
+ sub.extend(NATS::JetStream.const_get(:PullSubscription))
164
+ sub.jsi = NATS::JetStream.const_get(:JS)::Sub.new(
165
+ js: js, stream: stream, consumer: info.name,
166
+ nms: "#{js.prefix}.CONSUMER.MSG.NEXT.#{stream}.#{info.name}"
167
+ )
168
+ sub
169
+ end
170
+
136
171
  # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
137
172
  # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
138
173
  def create_kv_with_msg_ttl(name, **options)
data/lib/cosmo/logger.rb CHANGED
@@ -5,6 +5,8 @@ require "forwardable"
5
5
 
6
6
  module Cosmo
7
7
  module Logger
8
+ TRACE = -1 # Below DEBUG; opt in with COSMO_LOG_LEVEL=trace for high-frequency polling/loop noise
9
+
8
10
  module Context
9
11
  KEY = :cosmo_context
10
12
 
@@ -45,10 +47,21 @@ module Cosmo
45
47
  end
46
48
  end
47
49
 
50
+ # Adds TRACE (below DEBUG) on top of stdlib's fixed DEBUG..UNKNOWN severities.
51
+ class Instance < ::Logger
52
+ def trace(progname = nil, &)
53
+ add(TRACE, nil, progname, &)
54
+ end
55
+
56
+ def format_severity(severity)
57
+ severity == TRACE ? "TRACE" : super
58
+ end
59
+ end
60
+
48
61
  class << self
49
62
  extend Forwardable
50
63
 
51
- delegate %i[info error debug warn fatal] => :instance
64
+ delegate %i[info error debug warn fatal trace] => :instance
52
65
  end
53
66
 
54
67
  def self.with(...)
@@ -60,12 +73,17 @@ module Cosmo
60
73
  end
61
74
 
62
75
  def self.instance
63
- @instance ||= ::Logger.new($stdout).tap do |logger|
76
+ @instance ||= Instance.new($stdout).tap do |logger|
64
77
  logger.formatter = SimpleFormatter.new
65
- logger.level = ::Logger::Severity.coerce(ENV.fetch("COSMO_LOG_LEVEL", "info"))
78
+ logger.level = coerce_level(ENV.fetch("COSMO_LOG_LEVEL", "info"))
66
79
  end
67
80
  end
68
81
 
82
+ def self.coerce_level(level)
83
+ level.to_s.downcase == "trace" ? TRACE : ::Logger::Severity.coerce(level)
84
+ end
85
+ private_class_method :coerce_level
86
+
69
87
  def self.instance=(logger)
70
88
  @instance = logger
71
89
  end
@@ -60,14 +60,14 @@ module Cosmo
60
60
  stream_name = config[:stream].to_s
61
61
  ttl = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL))
62
62
  if @cache.fetch("#{stream_name}:paused", ttl:) { API::Stream.new(stream_name).paused? }
63
- Logger.debug "stream #{stream_name} is paused, skipping fetch"
63
+ Logger.trace "stream #{stream_name} is paused, skipping fetch"
64
64
  next
65
65
  end
66
66
  all_paused = false
67
67
 
68
68
  _, skip_t = consumer_state[stream_name]
69
69
  if skip_t && Time.now < skip_t
70
- Logger.debug "stream #{stream_name} is empty, backing off"
70
+ Logger.trace "stream #{stream_name} is empty, backing off"
71
71
  next
72
72
  end
73
73
  all_empty = false
@@ -79,9 +79,9 @@ module Cosmo
79
79
  next if skip_t && Time.now < skip_t
80
80
 
81
81
  timeout = fetch_timeout(config)
82
- Logger.debug "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
83
- messages = lock(stream_name) { fetch(subscription, batch_size: config[:batch_size], timeout:) }
84
- Logger.debug "fetched (#{messages&.size.to_i}) messages"
82
+ Logger.trace "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
83
+ messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
84
+ Logger.trace "fetched (#{messages&.size.to_i}) messages"
85
85
  if messages&.any?
86
86
  consumer_state.delete(stream_name)
87
87
  process(messages, processor)
@@ -104,14 +104,14 @@ module Cosmo
104
104
 
105
105
  if all_paused
106
106
  period = Utils::Duration.parse(ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP))
107
- Logger.debug "all streams paused, sleep=#{period}"
107
+ Logger.trace "all streams paused, sleep=#{period}"
108
108
  sleep(period)
109
109
  elsif all_empty
110
110
  next_wake = consumer_state.values.filter_map { |_, t| t }.min
111
111
  next unless next_wake # entry was deleted concurrently (messages arrived), re-loop immediately
112
112
 
113
113
  remaining = [next_wake - Time.now, 0.01].max
114
- Logger.debug "all streams empty, sleep=#{remaining}"
114
+ Logger.trace "all streams empty, sleep=#{remaining}"
115
115
  sleep(remaining)
116
116
  end
117
117
  end
@@ -158,11 +158,6 @@ module Cosmo
158
158
  Utils::Stopwatch.new
159
159
  end
160
160
 
161
- def lock(stream_name, &)
162
- @locks ||= Hash.new { |h, k| h[k] = Mutex.new }
163
- @locks[stream_name].synchronize(&)
164
- end
165
-
166
161
  def consumer_state
167
162
  @consumer_state ||= Concurrent::Map.new
168
163
  end
@@ -13,3 +13,188 @@ Cosmo::Utils::Warnings.silence do
13
13
  members = NATS::JetStream::PubAck.members + [:val]
14
14
  NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
15
15
  end
16
+
17
+ # Upstream bug in nats-pure 2.5.0 (https://github.com/nats-io/nats.rb):
18
+ # NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
19
+ # `batch > 1` branch, but the final "did we time out" check outside the case statement
20
+ # reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
21
+ # `start_time` is nil there -- normally masked because the `batch == 1` branch has its
22
+ # own timeout check (using a properly-set `t`) that raises first.
23
+ #
24
+ # It only surfaces once two threads call #fetch concurrently on the *same* subscription:
25
+ # @pending_queue/wait_for_msgs_cond are shared per-subscription state, and #dispatch's
26
+ # `wait_for_msgs_cond.signal` wakes exactly one arbitrary waiter -- not necessarily the
27
+ # one whose own request produced that wakeup. A thread woken by someone else's signal
28
+ # finds nothing left in the (already-drained) queue, so it skips its own inner timeout
29
+ # check and falls into the buggy tail check, raising `TypeError: nil can't be coerced
30
+ # into Float` instead of the intended timeout/empty result. Reproduced deterministically
31
+ # with N threads fetching in a loop against the same PullSubscription.
32
+ #
33
+ # Second, separate upstream bug in the same method: the post-wait timeout check below
34
+ # (`raise ... if MonotonicTime.since(t) > timeout`) fires unconditionally, unlike every
35
+ # other timeout check in this method (all guarded with `msgs.empty? &&`). If the unsynchronized
36
+ # pop just above it succeeded in grabbing a real, already-delivered message -- which can happen
37
+ # even after `timeout` has technically elapsed, since #dispatch's signal and this thread actually
38
+ # resuming are two different moments -- that message is already gone from @pending_queue with
39
+ # nowhere else to go, yet gets discarded here and a plain NATS::Timeout raised instead. Callers
40
+ # (including Cosmo::Processor#fetch) treat NATS::Timeout as "no messages", so the message is lost
41
+ # silently: never processed, never acked, and not redelivered until the consumer's ack_wait expires.
42
+ # Reproduces reliably with several threads fetching (batch: 1) concurrently against the same
43
+ # subscription, e.g. Cosmo::Job::Processor's thread pool.
44
+ #
45
+ # Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
46
+ # upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
47
+ # next nats-pure release and drop once fixed there.
48
+
49
+ # rubocop:disable all
50
+ module NATS
51
+ class JetStream
52
+ module PullSubscription
53
+ def fetch(batch = 1, params = {})
54
+ raise ::NATS::JetStream::Error.new("nats: invalid batch size") if batch < 1
55
+
56
+ t = MonotonicTime.now
57
+ start_time = t # fixes the upstream bug -- see comment above
58
+ timeout = params[:timeout] ||= 5
59
+ expires = (timeout * 1_000_000_000) - 100_000
60
+ next_req = { batch: batch }
61
+
62
+ msgs = []
63
+ case
64
+ when batch == 1
65
+ synchronize do
66
+ unless @pending_queue.empty?
67
+ msg = @pending_queue.pop
68
+ @pending_size -= msg.data.size
69
+ if JS.is_status_msg(msg)
70
+ case msg.header["Status"]
71
+ when JS::Status::NoMsgs then nil
72
+ when JS::Status::RequestTimeout then nil # Skip
73
+ else raise JS.from_msg(msg)
74
+ end
75
+ else
76
+ msgs << msg
77
+ end
78
+ end
79
+ end
80
+
81
+ next_req[:expires] = expires
82
+ if msgs.empty?
83
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
84
+ synchronize { wait_for_msgs_cond.wait(timeout) }
85
+
86
+ unless @pending_queue.empty?
87
+ msg = @pending_queue.pop
88
+ @pending_size -= msg.data.size
89
+ msgs << msg
90
+ end
91
+
92
+ raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(t) > timeout)
93
+
94
+ if JS.is_status_msg(msgs.first)
95
+ msg = msgs.first
96
+ case msg.header[JS::Header::Status]
97
+ when JS::Status::RequestTimeout then raise NATS::Timeout.new("nats: fetch request timeout")
98
+ else raise JS.from_msg(msgs.first)
99
+ end
100
+ end
101
+ end
102
+ when batch > 1
103
+ synchronize do
104
+ if batch <= @pending_queue.size
105
+ batch.times do
106
+ msg = @pending_queue.pop
107
+ @pending_size -= msg.data.size
108
+ if JS.is_status_msg(msg)
109
+ case msg.header[JS::Header::Status]
110
+ when JS::Status::NoMsgs, JS::Status::RequestTimeout then next
111
+ else raise JS.from_msg(msg)
112
+ end
113
+ else
114
+ msgs << msg
115
+ end
116
+ end
117
+ return msgs
118
+ end
119
+ end
120
+
121
+ next_req[:no_wait] = true
122
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
123
+
124
+ start_time = MonotonicTime.now
125
+ msg = nil
126
+
127
+ synchronize do
128
+ wait_for_msgs_cond.wait(timeout)
129
+ unless @pending_queue.empty?
130
+ msg = @pending_queue.pop
131
+ @pending_size -= msg.data.size
132
+ end
133
+ end
134
+
135
+ if !msg.nil? && JS.is_status_msg(msg)
136
+ case msg.header[JS::Header::Status]
137
+ when JS::Status::NoMsgs
138
+ next_req[:expires] = expires
139
+ next_req.delete(:no_wait)
140
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
141
+ when JS::Status::RequestTimeout
142
+ raise NATS::Timeout.new("nats: fetch request timeout")
143
+ else
144
+ raise JS.from_msg(msg)
145
+ end
146
+ else
147
+ msgs << msg unless msg.nil?
148
+ end
149
+
150
+ duration = MonotonicTime.since(start_time)
151
+ raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (duration > timeout)
152
+
153
+ needed = batch - msgs.count
154
+ while (needed > 0) && (MonotonicTime.since(start_time) < timeout)
155
+ duration = MonotonicTime.since(start_time)
156
+
157
+ synchronize do
158
+ if @pending_queue.empty?
159
+ deadline = timeout - duration
160
+ wait_for_msgs_cond.wait(deadline) if deadline > 0
161
+
162
+ duration = MonotonicTime.since(start_time)
163
+ if msgs.empty? && @pending_queue.empty? && (duration > timeout)
164
+ raise NATS::Timeout.new("nats: fetch timeout")
165
+ end
166
+ end
167
+
168
+ unless @pending_queue.empty?
169
+ msg = @pending_queue.pop
170
+ @pending_size -= msg.data.size
171
+
172
+ if JS.is_status_msg(msg)
173
+ case msg.header[JS::Header::Status]
174
+ when JS::Status::NoMsgs, JS::Status::RequestTimeout
175
+ duration = MonotonicTime.since(start_time)
176
+ if duration > timeout
177
+ raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty?
178
+
179
+ return msgs
180
+ end
181
+ else
182
+ raise JS.from_msg(msg)
183
+ end
184
+ else
185
+ msgs << msg
186
+ needed -= 1
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(start_time) > timeout)
194
+
195
+ msgs
196
+ end
197
+ end
198
+ end
199
+ end
200
+ # rubocop:enable all
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/sig/cosmo/client.rbs CHANGED
@@ -13,7 +13,7 @@ module Cosmo
13
13
 
14
14
  def publish: (::String subject, ::String payload, **untyped params) -> untyped
15
15
 
16
- def subscribe: (::String | Array[::String] subject, ::String consumer_name, Hash[Symbol, untyped] config) -> untyped
16
+ def subscribe: (::String | Array[::String] subject, ::String? consumer_name, Hash[Symbol, untyped] config) -> untyped
17
17
 
18
18
  def stream_info: (::String | Symbol name) -> untyped
19
19
 
@@ -39,6 +39,8 @@ module Cosmo
39
39
 
40
40
  def consumer_info: (::String stream_name, ::String consumer_name) -> NATS::JetStream::API::ConsumerInfo
41
41
 
42
+ def delete_consumer: (::String stream_name, ::String consumer_name) -> bool
43
+
42
44
  def get_message: (::String | Symbol name, **untyped options) -> NATS::JetStream::API::RawStreamMsg
43
45
 
44
46
  def delete_message: (::String name, ::Integer seq) -> Hash[::String, untyped]
@@ -51,6 +53,8 @@ module Cosmo
51
53
 
52
54
  private
53
55
 
56
+ def ephemeral_subscribe: (::String | Array[::String] subject, Hash[Symbol, untyped] config) -> untyped
57
+
54
58
  def create_kv_with_msg_ttl: (::String name, **untyped opts) -> untyped
55
59
  end
56
60
  end
data/sig/cosmo/logger.rbs CHANGED
@@ -1,5 +1,7 @@
1
1
  module Cosmo
2
2
  module Logger
3
+ TRACE: Integer
4
+
3
5
  module Context
4
6
  KEY: Symbol
5
7
 
@@ -20,6 +22,12 @@ module Cosmo
20
22
  def call: (::String severity, Time time, untyped _, ::String msg) -> ::String
21
23
  end
22
24
 
25
+ class Instance < ::Logger
26
+ def trace: (?::String? progname) ?{ () -> ::String } -> true
27
+
28
+ def format_severity: (Integer severity) -> ::String
29
+ end
30
+
23
31
  def self.info: (::String) -> void
24
32
 
25
33
  def self.error: (::String | Exception) -> void
@@ -30,6 +38,8 @@ module Cosmo
30
38
 
31
39
  def self.fatal: (::String) -> void
32
40
 
41
+ def self.trace: (::String | Exception) -> void
42
+
33
43
  def self.with: (**untyped) ?{ () -> void } -> void
34
44
 
35
45
  def self.without: (*Symbol) -> nil
@@ -10,7 +10,6 @@ module Cosmo
10
10
  @threads: Array[Thread]
11
11
  @cache: Utils::TTLCache
12
12
  @options: Hash[Symbol, untyped]
13
- @locks: Hash[::String, Mutex]
14
13
  @consumer_state: untyped
15
14
 
16
15
  def self.run: (*untyped) -> Processor
@@ -49,8 +48,6 @@ module Cosmo
49
48
 
50
49
  def stopwatch: () -> Utils::Stopwatch
51
50
 
52
- def lock: (::String stream_name) { () -> void } -> void
53
-
54
51
  def consumer_state: () -> untyped
55
52
  end
56
53
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cosmonats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin