nats-pure 2.6.0 → 2.7.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.
@@ -39,231 +39,329 @@ module NATS
39
39
  #
40
40
  # @!visibility public
41
41
  module PullSubscription
42
+ # For as long, in seconds, a server with leafnodes or gateways delivers
43
+ # to a new pull without checking for interest in its reply first
44
+ # (defaultGatewayRecentSubExpiration of nats-server).
45
+ UNCHECKED_PULL_AGE = 2
46
+
47
+ def self.extended(sub)
48
+ sub.instance_eval do
49
+ # The count of pulls, which names their replies, and the statuses
50
+ # that other fetches took for the fetches waiting for their pulls.
51
+ @pulls = 0
52
+ @pull_ends = {}
53
+ end
54
+ end
55
+
42
56
  # next_msg is not available for pull based subscriptions.
43
57
  # @raise [NATS::JetStream::Error]
44
58
  def next_msg(params = {})
45
59
  raise ::NATS::JetStream::Error.new("nats: pull subscription cannot use next_msg")
46
60
  end
47
61
 
48
- # fetch makes a request to be delivered more messages from a pull consumer.
62
+ # fetch pulls a batch of messages from a pull consumer. It returns as
63
+ # soon as it has the batch; otherwise it waits for more messages, and
64
+ # returns those that came once its timeout is up. With :no_wait, it
65
+ # takes what the server delivers at once, without waiting for more.
66
+ #
67
+ # Given a block, it passes each delivery to it as it comes, in the
68
+ # calling thread. Otherwise the messages wait for the fetch to end. The
69
+ # server delivers again those not acked within the ack_wait of the
70
+ # consumer, and the fetch returns each message once, as last delivered.
71
+ # Keep the timeout below the ack_wait, or ack in a block. When the block
72
+ # breaks off the fetch, what the server still delivers to its pull is
73
+ # left to the next fetch.
74
+ #
75
+ # @example Ack each message as it comes.
76
+ #
77
+ # psub.fetch(100, timeout: 60) do |msg|
78
+ # process(msg)
79
+ # msg.ack
80
+ # end
49
81
  #
50
82
  # @param batch [Fixnum] Number of messages to pull from the stream.
51
83
  # @param params [Hash] Options to customize the fetch request.
52
- # @option params [Float] :timeout Duration of the fetch request before it expires.
84
+ # @option params [Float] :timeout How long to wait for the batch, 5 seconds
85
+ # by default. The fetch then waits up to a second longer for the server
86
+ # to end its pull, so that it leaves no message behind. With :no_wait,
87
+ # how long to wait for what the server holds back, 1 second by default.
88
+ # @option params [Boolean] :no_wait Take what the server delivers at once,
89
+ # without waiting for more messages: none, an empty Array, when none are
90
+ # pending, or when other pulls wait for more than are pending. While the
91
+ # consumer may not deliver more until some are acked (max_ack_pending),
92
+ # the server holds the pull back, and the fetch returns what it got once
93
+ # its timeout is up. Messages that come later are left to the next fetch.
94
+ # @option params [String] :group Priority group to pull from, which the pulls
95
+ # of a consumer with a priority policy must name (requires nats-server v2.11.0).
96
+ # @option params [Integer] :min_pending With the overflow priority policy, deliver
97
+ # only while the consumer has at least this many messages pending; at least 1.
98
+ # @option params [Integer] :min_ack_pending With the overflow priority policy, deliver
99
+ # only while at least this many messages await acks; at least 1. Given both
100
+ # minimums, either will do.
101
+ # @option params [Integer] :priority With the prioritized priority policy, the priority
102
+ # of the pull, from 0, served first, to 9 (requires nats-server v2.12.0).
103
+ # @yieldparam msg [NATS::Msg] Each delivery, as it comes.
53
104
  # @return [Array<NATS::Msg>]
54
- def fetch(batch = 1, params = {})
105
+ # @raise [NATS::Timeout] When a fetch that waits got no messages before its timeout.
106
+ # @raise [ArgumentError] When the timeout is not a finite positive number, or a
107
+ # minimum is not an integer of at least 1.
108
+ # @raise [NATS::JetStream::Error::PinIdMismatch] With the pinned_client priority
109
+ # policy, when the server turned the pull of the fetch away before it got
110
+ # messages, as the subscription is no longer pinned: its pin expired or it
111
+ # was unpinned. The next fetch can be pinned again. The server counts the
112
+ # priority_timeout of the consumer from each pull it gets, not while a pull
113
+ # waits, so keep the fetch timeout, plus the time between fetches, below it.
114
+ # @raise [NATS::JetStream::Error] When the server ended the pull of the fetch
115
+ # with an error before it got messages.
116
+ def fetch(batch = 1, params = {}, &block)
55
117
  if batch < 1
56
118
  raise ::NATS::JetStream::Error.new("nats: invalid batch size")
57
119
  end
120
+ [:min_pending, :min_ack_pending].each do |min|
121
+ value = params[min]
122
+ next if value.nil? || (value.is_a?(Integer) && value >= 1)
58
123
 
59
- t = MonotonicTime.now
60
- timeout = params[:timeout] ||= 5
61
- expires = (timeout * 1_000_000_000) - 100_000
124
+ raise ArgumentError.new("nats: #{min} should be an integer of at least 1")
125
+ end
126
+ timeout = params[:timeout] || (params[:no_wait] ? 1 : 5)
127
+ unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
128
+ raise ArgumentError.new("nats: timeout should be a finite positive number")
129
+ end
130
+
131
+ deadline = MonotonicTime.now + timeout
132
+ msgs = []
133
+ # Take what earlier pulls delivered first.
134
+ while msgs.size < batch && (msg = next_pending)
135
+ collect(msgs, msg, &block)
136
+ end
137
+ return msgs if msgs.size == batch || (!msgs.empty? && MonotonicTime.now >= deadline)
138
+
139
+ # Like the nats.go jetstream package and nats.rs, pull once, for the
140
+ # rest of the batch.
62
141
  next_req = {
63
- batch: batch
142
+ batch: batch - msgs.size,
143
+ **params.slice(:group, :min_pending, :min_ack_pending, :priority)
64
144
  }
145
+ if params[:no_wait]
146
+ next_req[:no_wait] = true
147
+ pull_pending(msgs, next_req, deadline, &block)
148
+ else
149
+ # The pull expires with the timeout. As nats.go does, wait up to a
150
+ # second longer for the server to end it, so that it leaves nothing
151
+ # behind.
152
+ next_req[:expires] = pull_expires(deadline)
153
+ pull_and_wait(msgs, next_req, deadline + 1, &block)
154
+ raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty?
155
+ end
156
+ msgs
157
+ end
65
158
 
66
- msgs = []
67
- case
68
- when batch < 1
69
- raise ::NATS::JetStream::Error.new("nats: invalid batch size")
70
- when batch == 1
71
- ####################################################
72
- # Fetch (1) #
73
- ####################################################
74
-
75
- # Check if there is any pending message in the queue that is
76
- # ready to be consumed.
77
- synchronize do
78
- unless @pending_queue.empty?
79
- msg = @pending_queue.pop
80
- @pending_size -= msg.data.size
81
- # Check for a no msgs response status.
82
- if JS.is_status_msg(msg)
83
- case msg.header["Status"]
84
- when JS::Status::NoMsgs
85
- nil
86
- when JS::Status::RequestTimeout
87
- # Skip
88
- else
89
- raise JS.from_msg(msg)
90
- end
91
- else
92
- msgs << msg
93
- end
94
- end
95
- end
159
+ # consumer_info retrieves the current status of the pull subscription consumer.
160
+ # @param params [Hash] Options to customize API request.
161
+ # @option params [Float] :timeout Time to wait for response.
162
+ # @return [JetStream::API::ConsumerInfo] The latest ConsumerInfo of the consumer.
163
+ def consumer_info(params = {})
164
+ @jsi.js.consumer_info(@jsi.stream, @jsi.consumer, params)
165
+ end
96
166
 
97
- # Make lingering request with expiration.
98
- next_req[:expires] = expires
99
- if msgs.empty?
100
- # Make publish request and wait for response.
101
- @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
102
-
103
- # Wait for result of fetch or timeout.
104
- synchronize { wait_for_msgs_cond.wait(timeout) }
105
-
106
- unless @pending_queue.empty?
107
- msg = @pending_queue.pop
108
- @pending_size -= msg.data.size
109
-
110
- msgs << msg
111
- end
112
-
113
- duration = MonotonicTime.since(t)
114
- if duration > timeout
115
- raise ::NATS::Timeout.new("nats: fetch timeout")
116
- end
117
-
118
- # Should have received at least a message at this point,
119
- # if that is not the case then error already.
120
- if JS.is_status_msg(msgs.first)
121
- msg = msgs.first
122
- case msg.header[JS::Header::Status]
123
- when JS::Status::RequestTimeout
124
- raise NATS::Timeout.new("nats: fetch request timeout")
125
- else
126
- raise JS.from_msg(msgs.first)
127
- end
128
- end
129
- end
130
- when batch > 1
131
- ####################################################
132
- # Fetch (n) #
133
- ####################################################
134
-
135
- # Check if there already enough in the pending buffer.
136
- synchronize do
137
- if batch <= @pending_queue.size
138
- batch.times do
139
- msg = @pending_queue.pop
140
- @pending_size -= msg.data.size
141
-
142
- # Check for a no msgs response status.
143
- if JS.is_status_msg(msg)
144
- case msg.header[JS::Header::Status]
145
- when JS::Status::NoMsgs, JS::Status::RequestTimeout
146
- # Skip these
147
- next
148
- else
149
- raise JS.from_msg(msg)
150
- end
151
- else
152
- msgs << msg
153
- end
154
- end
155
-
156
- return msgs
157
- end
158
- end
167
+ private
168
+
169
+ # pull_and_wait sends a pull that waits for messages, with a reply of
170
+ # its own under the subscription, as ADR-13 has it. The messages it
171
+ # delivers carry the subjects of the stream, so that fetches share
172
+ # them, but the status that ends it comes to its reply.
173
+ def pull_and_wait(msgs, next_req, deadline, &block)
174
+ reply = synchronize { "#{@subject.chomp("*")}#{@pulls += 1}" }
175
+ synchronize { @pull_ends[reply] = nil }
176
+ pin_id = pull(next_req, reply)
177
+ receive(msgs, next_req[:batch], pin_id, -> { wait_pending(reply, deadline) }, &block)
178
+ ensure
179
+ synchronize { @pull_ends.delete(reply) }
180
+ end
159
181
 
160
- # Make publish request and wait any response.
161
- next_req[:no_wait] = true
162
- @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
182
+ # pull_pending sends a pull that does not wait, with a subscription of
183
+ # its own: unsubscribing ends the pull if the server holds it, as it
184
+ # does while the consumer may not deliver more messages.
185
+ def pull_pending(msgs, next_req, deadline, &block)
186
+ inbox = @nc.subscribe(@nc.new_inbox)
187
+ pulled = MonotonicTime.now
188
+ pin_id = pull(next_req, inbox.subject)
189
+ ended = receive(msgs, next_req[:batch], pin_id, -> { next_reply(inbox, deadline) }, &block)
190
+ ensure
191
+ release(inbox, ended ? 0 : pulled + UNCHECKED_PULL_AGE - MonotonicTime.now) if inbox
192
+ end
163
193
 
164
- # Not receiving even one is a timeout.
165
- start_time = MonotonicTime.now
166
- msg = nil
194
+ # receive collects the messages a pull delivers. It returns true once
195
+ # the pull delivered its batch or the server ended it, and false when
196
+ # nothing came before the deadline.
197
+ def receive(msgs, batch, pin_id, next_msg, &block)
198
+ batch.times do
199
+ msg = next_msg.call
200
+ return false if msg.nil?
201
+ next collect(msgs, msg, &block) unless JS.is_status_msg(msg)
167
202
 
168
- synchronize do
169
- wait_for_msgs_cond.wait(timeout)
203
+ forget_pin(pin_id) if msg.header[JS::Header::Status] == JS::Status::PinIdMismatch
204
+ # An error ends the fetch too, with the messages taken before it.
205
+ return true if pull_ended?(msg) || !msgs.empty?
170
206
 
171
- unless @pending_queue.empty?
172
- msg = @pending_queue.pop
173
- @pending_size -= msg.data.size
174
- end
175
- end
207
+ raise JS.from_msg(msg)
208
+ end
209
+ true
210
+ end
176
211
 
177
- # Check if the first message was a response saying that
178
- # there are no messages.
179
- if !msg.nil? && JS.is_status_msg(msg)
180
- case msg.header[JS::Header::Status]
181
- when JS::Status::NoMsgs
182
- # Make another request that does wait.
183
- next_req[:expires] = expires
184
- next_req.delete(:no_wait)
185
-
186
- @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
187
- when JS::Status::RequestTimeout
188
- raise NATS::Timeout.new("nats: fetch request timeout")
189
- else
190
- raise JS.from_msg(msg)
191
- end
192
- else
193
- msgs << msg unless msg.nil?
212
+ # release unsubscribes the inbox of a pull that did not wait, handing
213
+ # what came to it to the next fetches. Unless the pull ended, it waits
214
+ # until the server checks for interest before it delivers to the pull.
215
+ def release(inbox, linger)
216
+ if linger > 0
217
+ return Thread.new do
218
+ hand_over(inbox, MonotonicTime.now + linger)
219
+ release(inbox, 0)
194
220
  end
221
+ end
195
222
 
196
- # Check if have not received yet a single message.
197
- duration = MonotonicTime.since(start_time)
223
+ begin
224
+ inbox.unsubscribe
225
+ rescue NATS::IO::ConnectionClosedError, NATS::IO::BadSubscription
226
+ # The connection closed, which ended the pull too.
227
+ end
228
+ hand_over(inbox)
229
+ end
198
230
 
199
- if msgs.empty? && (duration > timeout)
200
- raise NATS::Timeout.new("nats: fetch timeout")
231
+ # hand_over adds the messages that come to the inbox of a pull that did
232
+ # not wait, until the deadline, to those of the subscription.
233
+ def hand_over(inbox, deadline = MonotonicTime.now)
234
+ while (msg = next_reply(inbox, deadline))
235
+ keep(msg) unless JS.is_status_msg(msg)
236
+ end
237
+ end
238
+
239
+ # keep adds a message to those delivered to the subscription, for the
240
+ # next fetches. Like the connection, it drops the message when the
241
+ # subscription holds as many as it may, and the server delivers it again.
242
+ def keep(msg)
243
+ synchronize do
244
+ return if @pending_queue.size >= pending_msgs_limit || @pending_size >= pending_bytes_limit
245
+
246
+ @pending_queue << msg
247
+ @pending_size += msg.data.size
248
+ wait_for_msgs_cond.signal
249
+ end
250
+ end
251
+
252
+ # collect adds a message to those of the fetch, and passes it to the
253
+ # block, if there is one. A message the server delivers again, as it
254
+ # was not acked in time, takes the place of its earlier delivery.
255
+ def collect(msgs, msg)
256
+ yield msg if block_given?
257
+ meta = msg.metadata
258
+ if meta && meta.num_delivered > 1
259
+ i = msgs.index { |held| held.metadata.sequence.stream == meta.sequence.stream }
260
+ end
261
+ i ? msgs[i] = msg : msgs << msg
262
+ end
263
+
264
+ # pull_ended? tells whether a status only says that a pull ended:
265
+ # 404 No Messages, or 408, as the pull expired or other pulls wait
266
+ # for more messages than are pending. Other statuses are errors.
267
+ def pull_ended?(msg)
268
+ [JS::Status::NoMsgs, JS::Status::RequestTimeout].include?(msg.header[JS::Header::Status])
269
+ end
270
+
271
+ # pull_expires is how long a pull may wait, in nanoseconds: until the
272
+ # deadline. Never 0, with which the pull would wait for good.
273
+ def pull_expires(deadline)
274
+ [((deadline - MonotonicTime.now) * 1_000_000_000).to_i, 1].max
275
+ end
276
+
277
+ # wait_pending takes the next message delivered to the subscription,
278
+ # or the status that ended the pull of the reply, waiting for one until
279
+ # the deadline; nil when none came. Other fetches can take the message
280
+ # it was woken up for.
281
+ def wait_pending(reply, deadline)
282
+ synchronize do
283
+ loop do
284
+ return @pull_ends.delete(reply) if @pull_ends[reply]
285
+
286
+ msg = next_pending(reply)
287
+ return msg if msg
288
+
289
+ remaining = deadline - MonotonicTime.now
290
+ return if remaining <= 0
291
+
292
+ wait_for_msgs_cond.wait(remaining)
293
+ end
294
+ end
295
+ end
296
+
297
+ # next_pending takes the next message delivered to the subscription,
298
+ # or the status that ended the pull of the reply, if there is one.
299
+ # Other statuses ended the pulls of other fetches, which get them if
300
+ # they still wait for them.
301
+ def next_pending(reply = nil)
302
+ synchronize do
303
+ while (msg = pop_pending)
304
+ return msg unless JS.is_status_msg(msg) && msg.subject != reply
305
+ next unless @pull_ends.key?(msg.subject)
306
+
307
+ @pull_ends[msg.subject] = msg
308
+ wait_for_msgs_cond.broadcast
201
309
  end
310
+ end
311
+ end
202
312
 
203
- needed = batch - msgs.count
204
- while (needed > 0) && (MonotonicTime.since(start_time) < timeout)
205
- duration = MonotonicTime.since(start_time)
206
-
207
- # Wait for the rest of the messages.
208
- synchronize do
209
- # Wait until there is a message delivered.
210
- if @pending_queue.empty?
211
- deadline = timeout - duration
212
- MonotonicTime.now
213
-
214
- wait_for_msgs_cond.wait(deadline) if deadline > 0
215
-
216
- duration = MonotonicTime.since(start_time)
217
- if msgs.empty? && @pending_queue.empty? && (duration > timeout)
218
- raise NATS::Timeout.new("nats: fetch timeout")
219
- end
220
- end
221
-
222
- unless @pending_queue.empty?
223
- msg = @pending_queue.pop
224
- @pending_size -= msg.data.size
225
-
226
- if JS.is_status_msg(msg)
227
- case msg.header[JS::Header::Status]
228
- when JS::Status::NoMsgs, JS::Status::RequestTimeout
229
- duration = MonotonicTime.since(start_time)
230
-
231
- if duration > timeout
232
- # Only received a subset of the messages.
233
- if !msgs.empty?
234
- return msgs
235
- else
236
- raise NATS::Timeout.new("nats: fetch timeout")
237
- end
238
- end
239
- else
240
- raise JS.from_msg(msg)
241
- end
242
-
243
- else
244
- # Add to the set of messages that will be returned.
245
- msgs << msg
246
- needed -= 1
247
- end
248
- end
249
- end # :end: synchronize
313
+ # next_reply takes the next reply to a pull that does not wait,
314
+ # waiting for one until the deadline; nil when none came.
315
+ def next_reply(inbox, deadline)
316
+ msg = inbox.synchronize do
317
+ while inbox.pending_queue.empty?
318
+ remaining = deadline - MonotonicTime.now
319
+ return if remaining <= 0
320
+
321
+ inbox.wait_for_msgs_cond.wait(remaining)
250
322
  end
323
+ inbox.pending_queue.pop.tap { |reply| inbox.pending_size -= reply.data.size }
251
324
  end
325
+ msg.sub = self
326
+ track_pin(msg)
327
+ end
328
+
329
+ # pull asks the server for messages, sending the pin id of the
330
+ # subscription, if it is pinned, as the messages it took last say.
331
+ # Returns the pin id it sent.
332
+ def pull(next_req, reply)
333
+ next_req[:id] = synchronize { @pin_id }
334
+ @nc.publish(@jsi.nms, JS.next_req_to_json(next_req), reply)
335
+ next_req[:id]
336
+ end
252
337
 
253
- # Check if timed out waiting for messages.
254
- if msgs.empty? && (MonotonicTime.since(start_time) > timeout)
255
- raise NATS::Timeout.new("nats: fetch timeout")
338
+ # pop_pending takes the next message delivered to the subscription,
339
+ # if there is one: the connection delivers under the same lock, so
340
+ # waiting for one here would stop it.
341
+ def pop_pending
342
+ synchronize do
343
+ return if @pending_queue.empty?
344
+
345
+ msg = @pending_queue.pop
346
+ @pending_size -= msg.data.size
347
+ track_pin(msg)
256
348
  end
349
+ end
257
350
 
258
- msgs
351
+ # track_pin keeps the pin id of the subscription. With the pinned_client
352
+ # priority policy, every message delivered to a pinned subscription
353
+ # carries its pin id, which it sends with its pulls.
354
+ def track_pin(msg)
355
+ pin_id = (msg.header || {})[JS::Header::PinId]
356
+ synchronize { @pin_id = pin_id } if pin_id
357
+ msg
259
358
  end
260
359
 
261
- # consumer_info retrieves the current status of the pull subscription consumer.
262
- # @param params [Hash] Options to customize API request.
263
- # @option params [Float] :timeout Time to wait for response.
264
- # @return [JetStream::API::ConsumerInfo] The latest ConsumerInfo of the consumer.
265
- def consumer_info(params = {})
266
- @jsi.js.consumer_info(@jsi.stream, @jsi.consumer, params)
360
+ # forget_pin drops the pin id a pull sent, which the server turned away
361
+ # (423) as the subscription is no longer pinned, unless the subscription
362
+ # got another one since.
363
+ def forget_pin(pin_id)
364
+ synchronize { @pin_id = nil if @pin_id == pin_id }
267
365
  end
268
366
  end
269
367
  private_constant :PullSubscription
@@ -16,6 +16,7 @@
16
16
  require_relative "kv"
17
17
  require_relative "jetstream/api"
18
18
  require_relative "jetstream/errors"
19
+ require_relative "jetstream/header"
19
20
  require_relative "jetstream/js"
20
21
  require_relative "jetstream/manager"
21
22
  require_relative "jetstream/msg"
@@ -69,7 +70,19 @@ module NATS
69
70
  # @return [Boolean] Indicates whether the published message is a duplicate.
70
71
  # @!attribute [domain] domain
71
72
  # @return [String] JetStream Domain that processed the ack response.
72
- PubAck = Struct.new(:stream, :seq, :duplicate, :domain, keyword_init: true)
73
+ # @!attribute [val] val
74
+ # @return [String] Value of the counter after the increment, set when publishing to a stream with counters enabled, unless the message was a duplicate.
75
+ # @!attribute [batch] batch
76
+ # @return [String] ID of the batch, set on the ack of an atomic batch commit.
77
+ # @!attribute [count] count
78
+ # @return [Integer] Number of messages the batch stored, set on the ack of an atomic batch commit.
79
+ PubAck = Struct.new(:stream, :seq, :duplicate, :domain, :val, :batch, :count,
80
+ keyword_init: true) do
81
+ # Fields added by newer servers are ignored, so that they cannot break publish.
82
+ def initialize(opts = {})
83
+ super(**opts.slice(*members))
84
+ end
85
+ end
73
86
 
74
87
  # publish produces a message for JetStream.
75
88
  #
@@ -77,21 +90,51 @@ module NATS
77
90
  # @param payload [String] The payload of the message.
78
91
  # @param params [Hash] Options to customize the publish message request.
79
92
  # @option params [Float] :timeout Time to wait for an PubAck response or an error.
80
- # @option params [Hash] :header NATS Headers to use for the message.
93
+ # @option params [Hash] :header NATS Headers to use for the message; the
94
+ # options below replace those that they set.
81
95
  # @option params [String] :stream Expected Stream to which the message is being published.
96
+ # @option params [Integer, Symbol] :ttl Seconds after which the stream
97
+ # removes the message, from 1 to 2**32, or :never to keep it past the max_age of
98
+ # the stream. The stream needs allow_msg_ttl (requires nats-server v2.11.0).
99
+ # @option params [Hash] :schedule Makes the message a schedule, which
100
+ # publishes it to the subject given as :target: once at the Time given
101
+ # as :at; every so many whole seconds, given as :every; or on a :cron
102
+ # expression with seconds, such as "0 30 * * * *", or "@hourly",
103
+ # "@daily", "@weekly", "@monthly" or "@yearly", in UTC or the IANA
104
+ # :time_zone, which only cron schedules take. :at is sent to the
105
+ # nanosecond. :source publishes the last message of that subject
106
+ # instead, :ttl gives the published messages a TTL, like the ttl
107
+ # option, and rollup: true rolls up the target with each. The target
108
+ # and source have to be subjects of the stream, which needs
109
+ # allow_msg_schedules, and allow_msg_ttl for :ttl. Another schedule
110
+ # on the subject replaces the schedule, and deleting its message stops
111
+ # it. Requires nats-server v2.12.0 for :at, :target and :ttl, and
112
+ # v2.14.0 for the others; v2.12 ignores :source and :rollup.
82
113
  # @raise [NATS::Timeout] When it takes too long to receive an ack response.
114
+ # @raise [ArgumentError] When an option is invalid, before the message is sent.
115
+ # @raise [NATS::JetStream::Error::APIError] When the stream refuses the
116
+ # message, as a stream that does not allow TTLs refuses one with a TTL.
117
+ # @raise [NATS::JetStream::Error::NoStreamResponse] When no stream takes
118
+ # the subject.
83
119
  # @return [PubAck] The pub ack response.
84
120
  def publish(subject, payload = "", **params)
85
121
  params[:timeout] ||= @opts[:timeout]
86
- if params[:stream]
87
- params[:header] ||= {}
88
- params[:header][JS::Header::ExpectedStream] = params[:stream]
122
+ # The options add to a copy of the header, which the caller may reuse.
123
+ options = {
124
+ Header::EXPECTED_STREAM => (params[:stream] if params[:stream]),
125
+ Header::MSG_TTL => (msg_ttl(params[:ttl]) if params[:ttl])
126
+ }.compact
127
+ if (schedule = params[:schedule])
128
+ raise ArgumentError.new("nats: invalid schedule #{schedule.inspect}, expected a Hash") unless schedule.is_a?(Hash)
129
+
130
+ options.merge!(schedule_header(**schedule))
89
131
  end
132
+ header = options.empty? ? params[:header] : params[:header].to_h.merge(options)
90
133
 
91
134
  # Send message with headers.
92
135
  msg = NATS::Msg.new(subject: subject,
93
136
  data: payload,
94
- header: params[:header])
137
+ header: header)
95
138
 
96
139
  begin
97
140
  resp = @nc.request_msg(msg, **params)
@@ -322,8 +365,8 @@ module NATS
322
365
  add_consumer(stream, config)
323
366
  end
324
367
 
325
- deliver = @nc.new_inbox
326
- sub = @nc.subscribe(deliver)
368
+ # Each pull gets a reply of its own under the subscription.
369
+ sub = @nc.subscribe("#{@nc.new_inbox}.*")
327
370
  sub.extend(PullSubscription)
328
371
 
329
372
  consumer = params[:consumer]
@@ -336,5 +379,51 @@ module NATS
336
379
  )
337
380
  sub
338
381
  end
382
+
383
+ private
384
+
385
+ # msg_ttl formats a message TTL as the server takes it. Longer TTLs than
386
+ # 2**32 seconds, some 136 years, overflow in the server, which then
387
+ # removes the message at once.
388
+ def msg_ttl(ttl)
389
+ return "never" if ttl == :never
390
+ unless ttl.is_a?(Integer) && ttl.between?(1, 2**32)
391
+ raise ArgumentError.new("nats: invalid ttl #{ttl.inspect}, expected whole seconds from 1 to 2**32, or :never")
392
+ end
393
+
394
+ ttl.to_s
395
+ end
396
+
397
+ # schedule_header makes the headers of a message schedule.
398
+ def schedule_header(target:, at: nil, every: nil, cron: nil, source: nil, ttl: nil, time_zone: nil, rollup: nil)
399
+ pattern = if at.is_a?(Time)
400
+ "@at #{at.getutc.iso8601(9)}"
401
+ elsif every.is_a?(Integer) && every >= 1
402
+ "@every #{every}s"
403
+ elsif nonempty_string?(cron)
404
+ cron
405
+ end
406
+ unless pattern && [at, every, cron].compact.size == 1
407
+ raise ArgumentError.new("nats: a schedule needs one of at: a Time, every: whole seconds from 1, or cron: an expression")
408
+ end
409
+ raise ArgumentError.new("nats: a schedule needs a target subject") unless nonempty_string?(target)
410
+ raise ArgumentError.new("nats: invalid schedule source #{source.inspect}") unless source.nil? || nonempty_string?(source)
411
+ raise ArgumentError.new("nats: invalid schedule time zone #{time_zone.inspect}") unless time_zone.nil? || nonempty_string?(time_zone)
412
+ raise ArgumentError.new("nats: only cron: schedules take a time zone") if time_zone && !cron
413
+ raise ArgumentError.new("nats: invalid schedule rollup #{rollup.inspect}") unless [nil, true, false].include?(rollup)
414
+
415
+ {
416
+ Header::SCHEDULE => pattern,
417
+ Header::SCHEDULE_TARGET => target,
418
+ Header::SCHEDULE_SOURCE => source,
419
+ Header::SCHEDULE_TTL => (msg_ttl(ttl) if ttl),
420
+ Header::SCHEDULE_TIME_ZONE => time_zone,
421
+ Header::SCHEDULE_ROLLUP => ("sub" if rollup)
422
+ }.compact
423
+ end
424
+
425
+ def nonempty_string?(value)
426
+ value.is_a?(String) && !value.empty?
427
+ end
339
428
  end
340
429
  end