tuber 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: 5bf9725eaee223b9a587d41a70472161997c0792a7e3ad7917488e8e5dbacb62
4
- data.tar.gz: 4759d84c9fae6eda87ed77f26817b6eae8d88fb2d80addf98723530c99551f7b
3
+ metadata.gz: 28e2397d8368c54a6711fa1db82b5c54ea649a4113e4da808b9dbc6db9cec1cb
4
+ data.tar.gz: dee769c5e37ff64726e1563150af3a25e2dc3f65405844f58b76bafbf4f7148f
5
5
  SHA512:
6
- metadata.gz: 712f68857c4fcbedeaee298e94499abc9134337b1cd30de8f32c6a77fc8e640062bd6b1f0ba45122031d79a3c85cba7622c228f2d4447427e0d90c95668c6c47
7
- data.tar.gz: 6bc153d12ffc33d3dcdd772ea31e8b2675a672779ace7812748bbd00c64346382bfea7c7d843ed0a24e610117f638f81445e0696dcfffa11633d93cc275cccd4
6
+ metadata.gz: 48a99c28b12b3f83cd1f24f743fabda6f06a97802c3e2633bb805f7309a3574508d45b3ba7c2ce1cfd7842b1f92159115e19f7331362c79d662c687f0b60af75
7
+ data.tar.gz: 60fa0ea0b9d9440ba798bf953f640f9a6c654bf3988bdca58bc51e037bfa347fefc606964c81a236335467cd5441ce4344ef9382a725e2920d69d8a871ba996f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ The tuber gem is a fork of [beaneater](https://github.com/beanstalkd/beaneater),
4
4
  beaneater 1.1.4. Entries from 1.1.4 down are beaneater's history, kept for reference.
5
5
  (0.0.1 was an empty placeholder published to claim the gem name.)
6
6
 
7
+ ## 0.6.0 (September 16 2026)
8
+
9
+ Reconnection release. The gem retried reconnects but not connects, so a
10
+ consumer that caught `Tuber::NotConnected` and built a new client — the obvious
11
+ thing to write — never reached the retrying path, and a server restart of a few
12
+ seconds was enough to kill it. `reconnect!` makes the existing healing
13
+ reachable, and `connect_retries` gives a cold start a window wider than the
14
+ outage. Existing behaviour is unchanged until `connect_retries` is raised, with
15
+ two exceptions noted below: `NotConnected` messages gained a suffix (the
16
+ historical text remains a strict prefix), and a connect error other than
17
+ `ECONNREFUSED` is now retried and surfaced as `NotConnected` rather than
18
+ escaping as a raw `Errno`.
19
+
20
+ * Add `Tuber#reconnect!` / `Connection#reconnect!`: re-establishes the connection and replays its tube state (watched tubes with their weights, the used tube, the reserve mode). This healing only ever ran when the connection itself noticed a dropped socket, so a consumer that caught `Tuber::NotConnected` had no way to reach it and had to build a new client — which starts with no tube state and has to re-watch and re-weight every tube by hand.
21
+ * Add `connect_retries` and `connect_retry_interval` configuration. The initial connect took exactly one attempt with no retry, so a cold start during a server restart — a web boot, a fresh `Tuber.new`, a daemon rebuilding its client after an error — failed where an established connection would have healed. Defaults to the historical single attempt; raising it also lifts the reconnect budget of connections already established, so one knob covers both.
22
+ * Retry a connect that fails for any reason the server might be unreachable (`ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, DNS failure, connect timeout), not only `ECONNREFUSED`. Those previously escaped as a raw `Errno` from the middle of a reconnect, a class no consumer thinks to rescue.
23
+ * `Tuber::NotConnected` now names the underlying error in its message and keeps it as the exception's `cause`. A bare `rescue` collapsed every connect failure into "Connection to beanstalk '...' is closed!", hiding whether the server refused, timed out, or was never resolved — exactly what you need during an outage.
24
+ * `Connection#close` no longer raises if the socket cannot be closed cleanly; the connection is gone either way and `reconnect!` must not fail on the way to replacing it.
25
+ * Fix a reconnect silently dropping per-tube weights and the connection's reserve mode ([#1](https://github.com/tuberq/tuber-gem/issues/1)). Both are per-connection server state that a fresh socket resets, so a consumer that healed across a brief server restart kept working but quietly fell back to FIFO. `Connection` now remembers the weight each tube was watched with and the mode set by `Tubes#reserve_mode`, and replays them (watch, then `ignore default`, then `reserve-mode`) when re-initialising tubes.
26
+ * Fix `_initialize_tubes` sending `ignore default` after a reconnect even when `default` was one of the watched tubes, which dropped it from the watch list.
27
+ * `Connection#add_to_watched` now takes an optional weight as a second argument, and `Connection#tube_weights` / `Connection#reserve_mode` expose the replayed state.
28
+
7
29
  ## 0.5.1 (August 13 2026)
8
30
 
9
31
  * Fix empty keyword arguments forwarded through `Tube#transmit`, `Tubes#transmit` and `Jobs#transmit` arriving as an extra positional `{}` on Ruby 2.7
data/Gemfile CHANGED
@@ -6,7 +6,9 @@ source 'https://rubygems.org'
6
6
  gemspec
7
7
 
8
8
  group :development do
9
- gem 'redcarpet', '~> 1'
9
+ # 3.5.1 fixes CVE-2020-26298 (XSS when processing quotes). The old '~> 1'
10
+ # pin was inherited from beaneater and capped us below the fix.
11
+ gem 'redcarpet', '~> 3.6', '>= 3.5.1'
10
12
  gem 'github-markup'
11
13
  gem 'yard'
12
14
  end
data/README.md CHANGED
@@ -72,6 +72,8 @@ Tuber.configure do |config|
72
72
  # config.resolv_timeout = nil
73
73
  # config.read_timeout = nil
74
74
  # config.write_timeout = nil
75
+ # config.connect_retries = 0
76
+ # config.connect_retry_interval = 1
75
77
  end
76
78
  ```
77
79
 
@@ -90,6 +92,47 @@ The above options are all defaults, so only include a configuration block if you
90
92
  @tuber.close
91
93
  ```
92
94
 
95
+ #### Surviving a server restart
96
+
97
+ An established connection heals itself: when a command notices the socket has
98
+ died, the connection reconnects, replays its tube state, and re-sends the
99
+ command (except for the non-idempotent verbs — `put`, `delete`, `release`,
100
+ `bury`, `touch`, `kick` — where a blind re-send could duplicate a job or act on
101
+ a reservation that died with the old socket, so the original error is raised
102
+ for the caller to decide).
103
+
104
+ That healing gets three connect attempts a second apart. A server restart
105
+ typically takes longer, so give the client a budget that outlasts it:
106
+
107
+ ```ruby
108
+ Tuber.configure do |config|
109
+ config.connect_retries = 15 # extra attempts, 0 = a single attempt
110
+ config.connect_retry_interval = 1 # seconds between them
111
+ end
112
+ ```
113
+
114
+ `connect_retries` covers both a cold start (`Tuber.new` while the server is
115
+ down) and reconnects on a client you already hold. It defaults to `0` — a
116
+ single attempt — which is the historical behaviour.
117
+
118
+ When a command does surface `Tuber::NotConnected`, heal the client you have
119
+ rather than building a new one:
120
+
121
+ ```ruby
122
+ begin
123
+ job = @tuber.tubes.reserve
124
+ rescue Tuber::NotConnected
125
+ @tuber.reconnect! # watched tubes, their weights, the used tube and the
126
+ retry # reserve mode all come back with it
127
+ end
128
+ ```
129
+
130
+ A fresh `Tuber.new` starts with no tube state, so a worker that reconnects that
131
+ way has to re-watch and re-weight every tube by hand — and will silently fall
132
+ back to FIFO if it forgets the reserve mode. `reconnect!` replays all of it.
133
+ It takes the same budget as the config, overridable per call:
134
+ `@tuber.reconnect!(tries: 30, retry_interval: 2)`.
135
+
93
136
  ### Tubes
94
137
 
95
138
  Tubes are named work queues. Jobs are `put` into the used tube and `reserve`d from watched tubes. Each tube has a _ready_, _delayed_, and _buried_ queue.
@@ -270,6 +313,10 @@ job = @tuber.tubes.reserve # batch-jobs selected 6x as often as email
270
313
 
271
314
  Tubes default to weight 1. Switch back with `reserve_mode(:fifo)`.
272
315
 
316
+ Weights and reserve mode are per-connection server state. The client remembers
317
+ both and replays them if the connection has to be re-established, so a worker
318
+ that heals across a server blip keeps reserving by weight.
319
+
273
320
  ### Batch Reserve (Tuber only)
274
321
 
275
322
  Reserve multiple jobs atomically in a single call:
@@ -342,6 +389,10 @@ Tuber.configure do |config|
342
389
  config.job_parser = lambda { |body| body }
343
390
  config.job_serializer = lambda { |body| body }
344
391
  config.tuber_url = 'localhost:11300'
392
+
393
+ # Connect attempts; see "Surviving a server restart" above.
394
+ config.connect_retries = 0
395
+ config.connect_retry_interval = 1
345
396
  end
346
397
  ```
347
398
 
@@ -15,6 +15,21 @@ class Tuber
15
15
  attr_accessor :read_timeout # socket read timeout in seconds
16
16
  attr_accessor :write_timeout # socket write timeout in seconds
17
17
 
18
+ # How hard a client tries to connect — "how long should this ride out a
19
+ # server restart", in one knob.
20
+ #
21
+ # An established connection has always retried when it notices a dropped
22
+ # socket (Tuber::Connection::MAX_RETRIES attempts), but the *initial*
23
+ # connect got exactly one: a cold start during an outage — a web boot, a
24
+ # fresh Tuber.new, a daemon rebuilding its client after an error — failed
25
+ # where a held connection would have healed.
26
+ #
27
+ # Defaults to that single attempt, so nothing changes until you raise it.
28
+ # Raising it covers the initial connect and lifts the reconnect budget of
29
+ # connections already established along with it.
30
+ attr_accessor :connect_retries # extra connect attempts, 0 = one shot
31
+ attr_accessor :connect_retry_interval # seconds between connect attempts
32
+
18
33
  def initialize
19
34
  @default_put_delay = 0
20
35
  @default_put_pri = 65536
@@ -26,6 +41,9 @@ class Tuber
26
41
  @resolv_timeout = nil
27
42
  @read_timeout = nil
28
43
  @write_timeout = nil
44
+ @connect_retries = 0
45
+ # Matches Tuber::Connection::DEFAULT_RETRY_INTERVAL, which is not loaded yet.
46
+ @connect_retry_interval = 1
29
47
  end
30
48
  end # Configuration
31
49
  end # Tuber
@@ -33,7 +33,17 @@ class Tuber
33
33
  # @returns [Array<String>] returns currently watched tube names
34
34
  # @!attribute tube_used
35
35
  # @returns [String] returns currently used tube name
36
- attr_accessor :tubes_watched, :tube_used
36
+ # @!attribute reserve_mode
37
+ # @returns [String, Symbol, nil] the reserve mode set on this connection,
38
+ # nil when it was never set (the server default is fifo)
39
+ attr_accessor :tube_used, :reserve_mode
40
+ attr_reader :tubes_watched
41
+
42
+ # @!attribute tube_weights
43
+ # @returns [Hash{String => Integer}] weight last watched with, per tube.
44
+ # Tubes watched without a weight are absent: the server resets a tube's
45
+ # weight to 1 on a plain `watch`, so "no weight" is state worth keeping.
46
+ attr_reader :tube_weights
37
47
 
38
48
  # Default port value for beanstalk connection
39
49
  DEFAULT_PORT = 11300
@@ -49,6 +59,25 @@ class Tuber
49
59
  # keeps the transparent retry.
50
60
  NON_IDEMPOTENT_COMMANDS = %w[put delete delete-batch release bury touch touch-all kick kick-job].freeze
51
61
 
62
+ # Errno names that mean "the server is not reachable right now" during a
63
+ # connect attempt. Every one of them is worth another try: a host coming
64
+ # back from a restart answers EHOSTUNREACH or ETIMEDOUT as readily as
65
+ # ECONNREFUSED, and a raw Errno escaping the connection is a class no
66
+ # consumer thinks to rescue.
67
+ CONNECT_ERROR_NAMES = %w[
68
+ ECONNREFUSED ECONNRESET ECONNABORTED ETIMEDOUT
69
+ EHOSTUNREACH EHOSTDOWN ENETUNREACH ENETDOWN EPIPE
70
+ ].freeze
71
+
72
+ # The resolved exception classes, skipping any Errno the platform lacks.
73
+ # SocketError covers a name that will not resolve (a DNS server restarting
74
+ # alongside the queue), IO::TimeoutError a connect_timeout on Ruby >= 3.2.
75
+ CONNECT_ERRORS = (
76
+ CONNECT_ERROR_NAMES.map { |name| Errno.const_get(name) if Errno.const_defined?(name) }.compact +
77
+ [SocketError] +
78
+ (defined?(IO::TimeoutError) ? [IO::TimeoutError] : [])
79
+ ).freeze
80
+
52
81
  # Initializes new connection.
53
82
  #
54
83
  # @param [String] address beanstalkd instance address.
@@ -61,15 +90,23 @@ class Tuber
61
90
  # @b.connection.host # => '127.0.0.1'
62
91
  # @b.connection.port # => '11300'
63
92
  #
93
+ # @raise [Tuber::NotConnected] Could not connect. The underlying error is
94
+ # named in the message and kept as the exception's +cause+.
95
+ #
64
96
  def initialize(address)
65
97
  @address = address || _host_from_env || Tuber.configuration.tuber_url
66
98
  @mutex = Mutex.new
67
99
  @tube_used = 'default'
68
100
  @tubes_watched = ['default']
69
-
70
- establish_connection
71
- rescue
72
- _raise_not_connected!
101
+ @tube_weights = {}
102
+ @reserve_mode = nil
103
+
104
+ _connect(tries: config.connect_retries.to_i + 1,
105
+ retry_interval: config.connect_retry_interval)
106
+ rescue Tuber::NotConnected
107
+ raise # already carries its cause; do not wrap it twice
108
+ rescue => ex
109
+ _raise_not_connected!(ex)
73
110
  end
74
111
 
75
112
  # Send commands to beanstalkd server via connection.
@@ -170,11 +207,50 @@ class Tuber
170
207
  #
171
208
  def close
172
209
  if @connection
173
- @connection.close
210
+ begin
211
+ @connection.close
212
+ rescue StandardError
213
+ # A socket whose peer has already vanished can fail to close
214
+ # cleanly. It is gone either way, and #reconnect! must not raise
215
+ # on the way to replacing it.
216
+ end
174
217
  @connection = nil
175
218
  end
176
219
  end
177
220
 
221
+ # Re-establishes this connection and replays its tube state onto the new
222
+ # socket: watched tubes with the weights they were watched with, the used
223
+ # tube, and the reserve mode. Safe to call whether the current socket is
224
+ # healthy, dropped, or already closed.
225
+ #
226
+ # This is the same healing the connection performs internally when a
227
+ # command notices the socket has died, exposed for consumers that hold a
228
+ # connection across an outage. Prefer it to building a new Tuber: a fresh
229
+ # Connection makes a single connect attempt by default (see
230
+ # Tuber::Configuration#connect_retries) and starts with no tube state, so
231
+ # the caller has to re-watch and re-weight everything by hand.
232
+ #
233
+ # @param [Integer, nil] tries Maximum number of connect attempts, nil for
234
+ # the configured default (see Tuber::Configuration#connect_retries)
235
+ # @param [Numeric, nil] retry_interval Seconds to wait between attempts,
236
+ # nil for the configured default
237
+ # @return [Tuber::Connection] self
238
+ # @raise [Tuber::NotConnected] Every connect attempt failed
239
+ # @example
240
+ # begin
241
+ # job = @conn.transmit("reserve")
242
+ # rescue Tuber::NotConnected
243
+ # @conn.reconnect! # watches, weights and reserve mode come back with it
244
+ # retry
245
+ # end
246
+ #
247
+ def reconnect!(tries: nil, retry_interval: nil)
248
+ _reconnect(tries: tries || _connect_tries,
249
+ retry_interval: retry_interval || config.connect_retry_interval)
250
+ _initialize_tubes
251
+ self
252
+ end
253
+
178
254
  # Returns string representation of job.
179
255
  #
180
256
  # @example
@@ -185,15 +261,34 @@ class Tuber
185
261
  end
186
262
  alias :inspect :to_s
187
263
 
188
- def add_to_watched(tube_name)
264
+ # Records a tube as watched, along with the weight it was watched with.
265
+ #
266
+ # @param [String] tube_name Name of the tube now being watched
267
+ # @param [Integer, nil] weight Weight passed to `watch`, nil for a plain watch
268
+ def add_to_watched(tube_name, weight = nil)
189
269
  @tubes_watched << tube_name
190
- @tubes_watched.uniq
270
+ @tubes_watched.uniq!
271
+ if weight
272
+ @tube_weights[tube_name] = weight
273
+ else
274
+ @tube_weights.delete(tube_name)
275
+ end
276
+ @tubes_watched
191
277
  end
192
278
 
193
279
  def remove_from_watched(tube_name)
280
+ @tube_weights.delete(tube_name)
194
281
  @tubes_watched.delete(tube_name)
195
282
  end
196
283
 
284
+ # Replaces the watched tube list, dropping weights for tubes that are no
285
+ # longer watched.
286
+ def tubes_watched=(tube_names)
287
+ @tubes_watched = tube_names
288
+ @tube_weights.keep_if { |name, _| @tubes_watched.include?(name) }
289
+ @tubes_watched
290
+ end
291
+
197
292
  protected
198
293
 
199
294
  # Establish a connection based on beanstalk address.
@@ -278,16 +373,24 @@ class Tuber
278
373
 
279
374
  private
280
375
 
376
+ # Replays this connection's tube state onto a freshly established socket.
377
+ # Weights and reserve mode are per-connection server state that a new socket
378
+ # resets, so a reconnect that only re-watched the tube names would silently
379
+ # drop a weighted consumer back to FIFO. Order matters: watch, then drop
380
+ # default, then set the mode.
281
381
  def _initialize_tubes
282
- if @tubes_watched != ['default']
382
+ if @tubes_watched != ['default'] || @tube_weights.any?
283
383
  tubes_watched.each do |t|
284
- transmit("watch #{t}", init: false)
384
+ weight = @tube_weights[t]
385
+ transmit(weight ? "watch #{t} #{weight}" : "watch #{t}", init: false)
285
386
  end
286
387
 
287
- transmit("ignore default", init: false)
388
+ transmit("ignore default", init: false) unless @tubes_watched.include?('default')
288
389
  end
289
390
 
290
391
  transmit("use #{tube_used}", init: false) if @tube_used != 'default'
392
+
393
+ transmit("reserve-mode #{@reserve_mode}", init: false) if @reserve_mode && @reserve_mode.to_s != 'fifo'
291
394
  end
292
395
 
293
396
  # Wrapper method for capturing certain failures and retry the payload block
@@ -304,7 +407,7 @@ class Tuber
304
407
  yield
305
408
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE,
306
409
  Errno::ECONNREFUSED => ex
307
- _reconnect(ex, retry_interval)
410
+ _reconnect(tries: _connect_tries, retry_interval: retry_interval)
308
411
  _initialize_tubes if init
309
412
  raise ex unless retransmit
310
413
  retry
@@ -318,19 +421,37 @@ class Tuber
318
421
  retry
319
422
  end
320
423
 
321
- # Tries to re-establish connection to the beanstalkd
424
+ # Connect attempts to allow when healing a connection that was already
425
+ # established. Never fewer than MAX_RETRIES, so the transparent retry
426
+ # behaves exactly as it always has by default; raising connect_retries
427
+ # lifts this with it, making one knob answer "how long should a client
428
+ # ride out a server restart" for cold starts and reconnects alike.
429
+ #
430
+ # @return [Integer] maximum number of connect attempts
431
+ def _connect_tries
432
+ [MAX_RETRIES, config.connect_retries.to_i + 1].max
433
+ end
434
+
435
+ # Drops the current socket and connects again.
322
436
  #
323
- # @param [Exception] original_exception The exception caused the retry
324
- # @param [Integer] retry_interval The time to wait before the next reconnect
325
437
  # @param [Integer] tries The maximum number of attempts to reconnect
326
- def _reconnect(original_exception, retry_interval, tries=MAX_RETRIES)
438
+ # @param [Numeric] retry_interval The time to wait before the next attempt
439
+ # @raise [Tuber::NotConnected] Every attempt failed
440
+ def _reconnect(tries: MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL)
327
441
  close
442
+ _connect(tries: tries, retry_interval: retry_interval)
443
+ end
444
+
445
+ # Connects, retrying while the server is refusing or unreachable.
446
+ #
447
+ # @param [Integer] tries The maximum number of connect attempts
448
+ # @param [Numeric] retry_interval The time to wait between attempts
449
+ # @raise [Tuber::NotConnected] Every attempt failed
450
+ def _connect(tries: MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL)
328
451
  establish_connection
329
- rescue Errno::ECONNREFUSED
452
+ rescue *CONNECT_ERRORS => ex
330
453
  tries -= 1
331
- if tries.zero?
332
- _raise_not_connected!
333
- end
454
+ _raise_not_connected!(ex) if tries <= 0
334
455
  sleep(retry_interval || DEFAULT_RETRY_INTERVAL)
335
456
  retry
336
457
  end
@@ -356,10 +477,23 @@ class Tuber
356
477
  [sec, usec].pack('l_l_')
357
478
  end
358
479
 
359
- # Raises an error to be triggered when the connection has failed
480
+ # Raises an error to be triggered when the connection has failed.
481
+ #
482
+ # The underlying error is named in the message as well as kept as the
483
+ # exception's +cause+. A log line reading only "Connection to beanstalk
484
+ # '...' is closed!" hides whether the server refused, timed out, or never
485
+ # resolved — which is exactly what you want to know during an outage.
486
+ #
487
+ # Only the two callers that are genuinely inside a rescue pass a cause.
488
+ # Defaulting to +$!+ would be wrong: a command issued from inside someone
489
+ # else's rescue block would name whatever they were handling.
490
+ #
491
+ # @param [Exception, nil] cause The error that stopped the connect
360
492
  # @raise [Tuber::NotConnected] Beanstalkd is no longer connected
361
- def _raise_not_connected!
362
- raise Tuber::NotConnected, "Connection to beanstalk '#{@host}:#{@port}' is closed!"
493
+ def _raise_not_connected!(cause = nil)
494
+ message = "Connection to beanstalk '#{@host}:#{@port}' is closed!"
495
+ message += " (#{cause.class}: #{cause.message})" if cause && !cause.is_a?(Tuber::NotConnected)
496
+ raise Tuber::NotConnected, message
363
497
  end
364
498
 
365
499
  end # Connection
@@ -103,7 +103,11 @@ class Tuber
103
103
  #
104
104
  # @api public
105
105
  def reserve_mode(mode)
106
- transmit("reserve-mode #{mode}")
106
+ res = transmit("reserve-mode #{mode}")
107
+ # Remembered so a reconnect can replay it: reserve mode is per-connection
108
+ # server state, and a fresh socket starts back at fifo.
109
+ client.connection.reserve_mode = mode
110
+ res
107
111
  end
108
112
 
109
113
  # Reserves a specific job by its ID.
@@ -202,7 +206,7 @@ class Tuber
202
206
  names.each do |t|
203
207
  cmd = weight ? "watch #{t} #{weight}" : "watch #{t}"
204
208
  transmit cmd
205
- client.connection.add_to_watched(t)
209
+ client.connection.add_to_watched(t, weight)
206
210
  end
207
211
  rescue BadFormatError => ex
208
212
  raise InvalidTubeName, "Tube in '#{ex.cmd}' is invalid!"
data/lib/tuber/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Tuber
4
4
  # Current version of gem.
5
- VERSION = "0.5.1"
5
+ VERSION = "0.6.0"
6
6
  end
data/lib/tuber.rb CHANGED
@@ -71,6 +71,27 @@ class Tuber
71
71
  connection.transmit("undrain")
72
72
  end
73
73
 
74
+ # Re-establishes the connection and replays its tube state: watched tubes
75
+ # with their weights, the used tube, and the reserve mode.
76
+ #
77
+ # Use this rather than building a new Tuber when a connection drops — a new
78
+ # client starts with no tube state and, by default, gets a single connect
79
+ # attempt, so it loses a race a reconnect would have won.
80
+ #
81
+ # @param [Integer, nil] tries Maximum number of connect attempts, nil for the
82
+ # configured default (see Tuber::Configuration#connect_retries)
83
+ # @param [Numeric, nil] retry_interval Seconds between attempts, nil for the
84
+ # configured default
85
+ # @return [Tuber] self
86
+ # @raise [Tuber::NotConnected] Every connect attempt failed
87
+ # @example
88
+ # @tuber_instance.reconnect!
89
+ #
90
+ def reconnect!(tries: nil, retry_interval: nil)
91
+ connection.reconnect!(tries: tries, retry_interval: retry_interval)
92
+ self
93
+ end
94
+
74
95
  # Closes the related connection
75
96
  #
76
97
  # @example
@@ -228,6 +228,131 @@ describe Tuber::Connection do
228
228
  # non-retransmitted command (delete/put) now surfaces as a failure.
229
229
  $called = true
230
230
  end
231
+
232
+ it "replays tube weights and reserve mode after reconnect" do
233
+ require_tuber!
234
+ client = Tuber.new(tuber_address)
235
+ client.tubes.watch("reconn_heavy", weight: 8)
236
+ client.tubes.watch("reconn_light", weight: 1)
237
+ client.tubes.ignore("default")
238
+ client.tubes.reserve_mode(:weighted)
239
+
240
+ $replayed = []
241
+ $eof_pending = true
242
+ TCPSocket.prepend Module.new {
243
+ def write(data)
244
+ $replayed << data.to_s if $replayed
245
+ super
246
+ end
247
+
248
+ def readline
249
+ if $eof_pending
250
+ $eof_pending = false
251
+ raise EOFError
252
+ end
253
+
254
+ super
255
+ end
256
+ }
257
+
258
+ client.connection.transmit("list-tubes-watched")
259
+ sent = $replayed
260
+
261
+ assert_includes sent, "watch reconn_heavy 8\r\n"
262
+ assert_includes sent, "watch reconn_light 1\r\n"
263
+ assert_includes sent, "reserve-mode weighted\r\n"
264
+ # Ordering matters: watch, then drop default, then set the mode.
265
+ assert sent.index("watch reconn_heavy 8\r\n") < sent.index("ignore default\r\n"),
266
+ "expected the watches to be replayed before 'ignore default'"
267
+ assert sent.index("ignore default\r\n") < sent.index("reserve-mode weighted\r\n"),
268
+ "expected 'reserve-mode' to be replayed after the tubes are watched"
269
+ ensure
270
+ # Leave the prepended module inert for the rest of the suite.
271
+ $eof_pending = false
272
+ $replayed = nil
273
+ client.close if client
274
+ end
275
+
276
+ it "keeps watching default across a reconnect when default is watched" do
277
+ client = Tuber.new(tuber_address)
278
+ client.tubes.watch("reconn_extra")
279
+
280
+ $replayed = []
281
+ $eof_pending = true
282
+ TCPSocket.prepend Module.new {
283
+ def write(data)
284
+ $replayed << data.to_s if $replayed
285
+ super
286
+ end
287
+
288
+ def readline
289
+ if $eof_pending
290
+ $eof_pending = false
291
+ raise EOFError
292
+ end
293
+
294
+ super
295
+ end
296
+ }
297
+
298
+ client.connection.transmit("list-tubes-watched")
299
+ sent = $replayed
300
+
301
+ assert_includes sent, "watch reconn_extra\r\n"
302
+ refute_includes sent, "ignore default\r\n"
303
+ assert_equal %w[default reconn_extra].sort,
304
+ client.tubes.watched.map(&:name).sort
305
+ ensure
306
+ $eof_pending = false
307
+ $replayed = nil
308
+ client.close if client
309
+ end
310
+
311
+ it "still reserves by weight after a reconnect" do
312
+ require_tuber!
313
+ consumer = Tuber.new(tuber_address)
314
+ consumer.tubes.watch("reconn_heavy", weight: 8)
315
+ consumer.tubes.watch("reconn_light", weight: 1)
316
+ consumer.tubes.ignore("default")
317
+ consumer.tubes.reserve_mode(:weighted)
318
+
319
+ producer = Tuber.new(tuber_address)
320
+ 100.times do |i|
321
+ producer.tubes["reconn_heavy"].put("h#{i}")
322
+ producer.tubes["reconn_light"].put("l#{i}")
323
+ end
324
+
325
+ $eof_pending = true
326
+ TCPSocket.prepend Module.new {
327
+ def readline
328
+ if $eof_pending
329
+ $eof_pending = false
330
+ raise EOFError
331
+ end
332
+
333
+ super
334
+ end
335
+ }
336
+
337
+ counts = Hash.new(0)
338
+ 60.times do
339
+ job = consumer.tubes.reserve(2)
340
+ counts[job.body[0]] += 1
341
+ job.delete
342
+ end
343
+
344
+ # 8:1 over interleaved puts is ~53/7; FIFO would be ~30/30.
345
+ assert counts["h"] > 3 * counts["l"],
346
+ "expected weighted reserve after reconnect, got heavy=#{counts["h"]} light=#{counts["l"]}"
347
+ ensure
348
+ $eof_pending = false
349
+ if producer
350
+ producer.connection.transmit("flush-tube reconn_heavy") rescue nil
351
+ producer.connection.transmit("flush-tube reconn_light") rescue nil
352
+ producer.close
353
+ end
354
+ consumer.close if consumer
355
+ end
231
356
  end # transmit
232
357
 
233
358
  describe 'for idempotent insert response' do
@@ -251,6 +376,217 @@ describe Tuber::Connection do
251
376
  end
252
377
  end # idempotent insert response
253
378
 
379
+ describe 'for #reconnect!' do
380
+ before do
381
+ @host = tuber_address
382
+ @bc = Tuber::Connection.new(@host)
383
+ end
384
+
385
+ after do
386
+ @bc.close rescue nil
387
+ end
388
+
389
+ it "re-establishes a connection that was closed" do
390
+ @bc.close
391
+ assert_nil @bc.connection
392
+
393
+ @bc.reconnect!
394
+
395
+ assert_kind_of TCPSocket, @bc.connection
396
+ assert_equal 'OK', @bc.transmit('stats')[:status]
397
+ end
398
+
399
+ it "replays watched tubes, weights and reserve mode" do
400
+ require_tuber!
401
+ client = Tuber.new(tuber_address)
402
+ client.tubes.watch("reconn_bang_heavy", weight: 8)
403
+ client.tubes.watch("reconn_bang_light", weight: 1)
404
+ client.tubes.ignore("default")
405
+ client.tubes.reserve_mode(:weighted)
406
+
407
+ $replayed = []
408
+ TCPSocket.prepend Module.new {
409
+ def write(data)
410
+ $replayed << data.to_s if $replayed
411
+ super
412
+ end
413
+ }
414
+
415
+ client.connection.reconnect!
416
+ sent = $replayed
417
+
418
+ assert_includes sent, "watch reconn_bang_heavy 8\r\n"
419
+ assert_includes sent, "watch reconn_bang_light 1\r\n"
420
+ assert_includes sent, "ignore default\r\n"
421
+ assert_includes sent, "reserve-mode weighted\r\n"
422
+ assert_equal %w[reconn_bang_heavy reconn_bang_light].sort,
423
+ client.tubes.watched.map(&:name).sort
424
+ ensure
425
+ # Leave the prepended module inert for the rest of the suite.
426
+ $replayed = nil
427
+ client.close if client
428
+ end
429
+
430
+ it "retries while the server is still refusing connections" do
431
+ # The outage shape: the socket is gone and the server will not accept
432
+ # for another second or two. A single connect attempt loses that race.
433
+ @bc.close
434
+ real_socket = TCPSocket.new(tuber_address, 11300)
435
+ TCPSocket.stubs(:new).
436
+ raises(Errno::ECONNREFUSED.new).then.
437
+ raises(Errno::ECONNREFUSED.new).then.
438
+ returns(real_socket)
439
+
440
+ @bc.reconnect!(retry_interval: 0.01)
441
+
442
+ assert_same real_socket, @bc.connection
443
+ end
444
+
445
+ it "retries a connect that fails for a reason other than ECONNREFUSED" do
446
+ # A remote host mid-restart answers EHOSTUNREACH or ETIMEDOUT as readily
447
+ # as ECONNREFUSED; those must not escape as a raw Errno either.
448
+ @bc.close
449
+ real_socket = TCPSocket.new(tuber_address, 11300)
450
+ TCPSocket.stubs(:new).
451
+ raises(Errno::EHOSTUNREACH.new).then.
452
+ returns(real_socket)
453
+
454
+ @bc.reconnect!(retry_interval: 0.01)
455
+
456
+ assert_same real_socket, @bc.connection
457
+ end
458
+
459
+ it "raises NotConnected naming the underlying error when the server stays down" do
460
+ @bc.close
461
+ TCPSocket.stubs(:new).raises(Errno::ECONNREFUSED.new)
462
+
463
+ err = assert_raises(Tuber::NotConnected) { @bc.reconnect!(retry_interval: 0.01) }
464
+ assert_match(/ECONNREFUSED/, err.message)
465
+ assert_kind_of Errno::ECONNREFUSED, err.cause
466
+ end
467
+
468
+ it "is reachable from the Tuber instance" do
469
+ client = Tuber.new(tuber_address)
470
+ client.tubes.watch("reconn_delegate")
471
+
472
+ client.reconnect!
473
+
474
+ assert_includes client.tubes.watched.map(&:name), "reconn_delegate"
475
+ ensure
476
+ client.close if client
477
+ end
478
+ end # reconnect!
479
+
480
+ describe 'for connect retries' do
481
+ after do
482
+ Tuber.configure do |config|
483
+ config.connect_retries = 0
484
+ config.connect_retry_interval = Tuber::Connection::DEFAULT_RETRY_INTERVAL
485
+ end
486
+ end
487
+
488
+ it "should make a single connect attempt by default" do
489
+ TCPSocket.expects(:new).times(1).raises(Errno::ECONNREFUSED.new)
490
+
491
+ assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost') }
492
+ end
493
+
494
+ it "should retry the initial connect when connect_retries is configured" do
495
+ real_socket = TCPSocket.new(tuber_address, 11300)
496
+ Tuber.configure do |config|
497
+ config.connect_retries = 3
498
+ config.connect_retry_interval = 0.01
499
+ end
500
+
501
+ TCPSocket.stubs(:new).
502
+ raises(Errno::ECONNREFUSED.new).then.
503
+ raises(Errno::ECONNREFUSED.new).then.
504
+ returns(real_socket)
505
+
506
+ bc = Tuber::Connection.new(tuber_address)
507
+
508
+ assert_same real_socket, bc.connection
509
+ end
510
+
511
+ it "should give up after connect_retries and name the cause" do
512
+ Tuber.configure do |config|
513
+ config.connect_retries = 2
514
+ config.connect_retry_interval = 0.01
515
+ end
516
+ TCPSocket.expects(:new).times(3).raises(Errno::ECONNREFUSED.new)
517
+
518
+ err = assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost') }
519
+ assert_match(/ECONNREFUSED/, err.message)
520
+ assert_kind_of Errno::ECONNREFUSED, err.cause
521
+ end
522
+
523
+ it "should lift an established connection's reconnect budget too" do
524
+ # One knob for "how long should a client ride out a server restart":
525
+ # raising connect_retries has to cover the connection you already hold,
526
+ # not only a cold start.
527
+ bc = Tuber::Connection.new(tuber_address)
528
+ real_socket = TCPSocket.new(tuber_address, 11300)
529
+
530
+ Tuber.configure do |config|
531
+ config.connect_retries = 5
532
+ config.connect_retry_interval = 0.01
533
+ end
534
+
535
+ # One dropped read, then four refused connects: past the built-in budget
536
+ # of MAX_RETRIES, inside the configured one.
537
+ $eof_pending = true
538
+ TCPSocket.prepend Module.new {
539
+ def readline
540
+ if $eof_pending
541
+ $eof_pending = false
542
+ raise EOFError
543
+ end
544
+
545
+ super
546
+ end
547
+ }
548
+ TCPSocket.stubs(:new).
549
+ raises(Errno::ECONNREFUSED.new).then.
550
+ raises(Errno::ECONNREFUSED.new).then.
551
+ raises(Errno::ECONNREFUSED.new).then.
552
+ raises(Errno::ECONNREFUSED.new).then.
553
+ returns(real_socket)
554
+
555
+ assert_equal 'OK', bc.transmit('stats')[:status]
556
+ assert_same real_socket, bc.connection
557
+ ensure
558
+ $eof_pending = false
559
+ end
560
+ end # connect retries
561
+
562
+ describe 'for NotConnected messages' do
563
+ it "should keep the historical message as a prefix" do
564
+ # Consumers match on this string. The cause is appended, never inserted.
565
+ TCPSocket.stubs(:new).raises(Errno::ECONNREFUSED.new)
566
+
567
+ err = assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost:8544') }
568
+
569
+ assert err.message.start_with?("Connection to beanstalk 'localhost:8544' is closed!"),
570
+ "expected the historical message as a prefix, got #{err.message.inspect}"
571
+ end
572
+
573
+ it "should not blame an unrelated in-flight exception" do
574
+ # Commands are very often issued from inside someone else's rescue block,
575
+ # where $! is still set. The cause named has to be the one that stopped
576
+ # this connect, not whatever the caller happened to be handling.
577
+ bc = Tuber::Connection.new(tuber_address)
578
+ bc.close
579
+
580
+ err = begin
581
+ raise ArgumentError, "something else entirely"
582
+ rescue ArgumentError
583
+ assert_raises(Tuber::NotConnected) { bc.transmit('stats') }
584
+ end
585
+
586
+ assert_equal "Connection to beanstalk '#{bc.host}:#{bc.port}' is closed!", err.message
587
+ end
588
+ end # NotConnected messages
589
+
254
590
  describe 'for #close' do
255
591
  before do
256
592
  @host = 'localhost'
data/test/tubes_test.rb CHANGED
@@ -128,13 +128,13 @@ describe Tuber::Tubes do
128
128
 
129
129
  it 'should send watch command with weight' do
130
130
  @connection.expects(:transmit).with("watch foo 4").returns({status: "WATCHING", id: "2"})
131
- @connection.expects(:add_to_watched).with("foo")
131
+ @connection.expects(:add_to_watched).with("foo", 4)
132
132
  @tubes.watch('foo', weight: 4)
133
133
  end
134
134
 
135
135
  it 'should send watch command without weight by default' do
136
136
  @connection.expects(:transmit).with("watch foo").returns({status: "WATCHING", id: "2"})
137
- @connection.expects(:add_to_watched).with("foo")
137
+ @connection.expects(:add_to_watched).with("foo", nil)
138
138
  @tubes.watch('foo')
139
139
  end
140
140
  end # watch with weight
@@ -149,13 +149,26 @@ describe Tuber::Tubes do
149
149
 
150
150
  it 'should send reserve-mode weighted command' do
151
151
  @connection.expects(:transmit).with("reserve-mode weighted").returns({status: "USING", id: "weighted"})
152
+ @connection.expects(:reserve_mode=).with(:weighted)
152
153
  @tubes.reserve_mode(:weighted)
153
154
  end
154
155
 
155
156
  it 'should send reserve-mode fifo command' do
156
157
  @connection.expects(:transmit).with("reserve-mode fifo").returns({status: "USING", id: "fifo"})
158
+ @connection.expects(:reserve_mode=).with(:fifo)
157
159
  @tubes.reserve_mode(:fifo)
158
160
  end
161
+
162
+ it 'should record the mode on the connection so a reconnect can replay it' do
163
+ require_tuber!
164
+ client = Tuber.new(tuber_address)
165
+ begin
166
+ client.tubes.reserve_mode(:weighted)
167
+ assert_equal :weighted, client.connection.reserve_mode
168
+ ensure
169
+ client.close
170
+ end
171
+ end
159
172
  end # reserve_mode
160
173
 
161
174
  describe "for #reserve" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tuber
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
  - Dan Milne
@@ -97,7 +97,6 @@ files:
97
97
  - LICENSE.txt
98
98
  - README.md
99
99
  - Rakefile
100
- - TODO
101
100
  - examples/demo.rb
102
101
  - lib/tuber.rb
103
102
  - lib/tuber/configuration.rb
data/TODO DELETED
@@ -1 +0,0 @@
1
- - Remove connection from pool if it's not responding and be able to add more connections and reattempt later