beaneater 1.1.4 → 1.2.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: 27dbb64059340085f2e89899e550b2329db2043e96491ae2561b2dd4f2bb34ea
4
- data.tar.gz: 3bf0277effe249cbbcd95e6643c6d5f7c8bd7a3efeee3a3e86f2cd6c53b59402
3
+ metadata.gz: 7397bf5f83a1ba3ff93d90fb1ee7718d8dcaf90ac45ecdedd236fe4fe666c5eb
4
+ data.tar.gz: e3f0776719d39a2eb235a921b505bddb3af25be0c915718be3b24a46dd0afd0b
5
5
  SHA512:
6
- metadata.gz: 65c0e86ba162862f43ec6bf3d4c0f45b1e30821f7bd7a891d8833be1312d256d2ec6999406bee08c5bf0e151ce2a599a7274c9179f3b64563d6168fc02f39888
7
- data.tar.gz: ab0f650fade61572fb3b1a8c57c3e0c15634e826f1a5cd2635ef07e4270ce472f8b53c02488028b14c25a294498b3b494854d3f52e798109a61ef7cb957bc674
6
+ metadata.gz: 9e6b026dbb985f693b2fb0c494084223f86100850210cef83af8d6c7a951e6f81b2dd620262d5f28fa4a18ec8c26542409c45fd9404199dba766470af8265af1
7
+ data.tar.gz: a73f631654a1147afcaed139b24b8ec1748fde51b02ff9e84492e8ccdcae81cc84177951b5faebf5d8c06189188b7448e21c346f2b2650219467bcbe1f62b1c8
@@ -22,10 +22,10 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  strategy:
24
24
  matrix:
25
- ruby-version: ['2.6', '2.7', '3.0', '3.1']
25
+ ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0']
26
26
 
27
27
  steps:
28
- - uses: actions/checkout@v3
28
+ - uses: actions/checkout@v4
29
29
  - name: Download & Extract beanstalkd
30
30
  run: curl -L https://github.com/beanstalkd/beanstalkd/archive/refs/tags/v1.13.tar.gz | tar xz
31
31
  - name: Make beanstalkd
@@ -40,3 +40,5 @@ jobs:
40
40
  run: ./beanstalkd-1.13/beanstalkd &
41
41
  - name: Run tests
42
42
  run: bundle exec rake test:full
43
+ env:
44
+ BEANSTALKD_BIN: ${{ github.workspace }}/beanstalkd-1.13/beanstalkd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # CHANGELOG for Beaneater
2
2
 
3
+ ## 1.2.0 (October 31 2026)
4
+
5
+ * `Jobs#process!` buries a job whose body the `job_parser` rejects (`JSON.parse` on a non-JSON job, say); it used to stay reserved until its TTR ran out and then fail again, for ever. A parser that raises `AbortProcessingError` has its job buried and stops the loop, as a handler's abort does. The job is not buried when the parser raises `NotConnected` while this client's connection is gone (`process!` raises it, and beanstalkd releases the job), an exception that isn't a `StandardError` (`process!` raises it, and the job stays reserved until its TTR runs out or the connection closes), or one that can't be copied (raised untagged, so `process!` can't tell which job it came from: the job stays reserved and `process!` keeps failing on it; make parser exceptions copyable). A plain `tubes.reserve` raises a copy of the parser's error with the same class, message, cause and backtrace (a frozen exception, for which Ruby records no backtrace, gets one that starts in beaneater), so `rescue JSON::ParserError` keeps working; the copy is also a `Beaneater::JobParseFailure`, whose `#job_id` names the job, still reserved by the connection (@nesquena)
6
+ * `close` while a command is waiting on the connection (a `reserve`, say), whether from a signal handler or from another thread, now makes that command raise `NotConnected`; 1.1.x raised `IOError`, and on Ruby 3.3 and 3.4 a `close` from a signal handler could crash the process. A `close` from a `job_parser` lets its command finish, then closes the socket (@nesquena)
7
+ * Drop the connection when a command is interrupted mid-reply (for example by `Timeout.timeout`), so the next command no longer reads the stale reply (reported by @tdegrunt)
8
+ * Honour `stop!` while `process!` is waiting for a job with `reserve_timeout` set (reported by @martint17r)
9
+ * Keep `process!` running when a handler deletes or releases its job, or overruns its TTR, and then raises; it used to stop with `NotFoundError` or `JobNotReserved`. A handler that raises one of those (or `TimedOutError`) while its job is still reserved now has the job buried like any other failure, instead of leaving it reserved and forgotten, which made `process!` reserve in a tight loop of `DEADLINE_SOON` replies near the job's TTR. To have such a job released for another try instead, add the error to the handler's `retry_on` (`jobs.register(tube, :retry_on => [Beaneater::NotFoundError])`); it is released up to `max_retries` times, then buried (reported by @pagojo)
10
+ * Reconnect on the next command after the connection was lost, restoring the used and watched tubes (while beanstalkd is down each command now retries for about 2 seconds before raising `NotConnected`, and gives up after 3 reconnects when every new connection is dropped at once; set `connect_timeout` in web apps that enqueue during requests), and make `process!` raise `NotConnected` instead of looping while its beanstalkd is down (a handler that raises `NotConnected` for another server still has its job buried, as before) (reported by @b-sridhar and @Meekohi)
11
+ * Test on Ruby 2.7 to 3.4 and 4.0 in CI (dropping 2.6), and update `actions/checkout` to v4 (@nesquena)
12
+ * Require Ruby 2.7 or newer, declared in the gemspec, so older Rubies get a clear install-time error instead of failing at runtime (the code needs at least 2.5 for `Hash#slice`), #83 (@nesquena; reported by @hkmaly)
13
+ * Make `read_timeout` and `write_timeout` take effect: they set `SO_RCVTIMEO`/`SO_SNDTIMEO`, which have no effect on Ruby 3 (Ruby waits on the socket itself), so a server that stopped answering still blocked forever. Beaneater now enforces both itself, adds `read_timeout` to `reserve(timeout)`'s own wait, never cuts off a plain `reserve`, and on timeout raises the new `Beaneater::SocketTimeout` (a `NotConnected`) and drops the connection, so the next command reconnects. `read_timeout` and `write_timeout` may be numeric strings. A `SocketTimeout` on `put` is ambiguous (beanstalkd may have stored the job), so retrying it can enqueue the job twice (@nesquena)
14
+ * Fix a raising `job_parser` (e.g. `JSON.parse` on a non-JSON job) leaving the connection one reply out of step, so later commands returned the wrong replies (@nesquena)
15
+ * Fix stats and tube lists mangling values since 1.1.2: `-`, `:` and `~` were rewritten to `_`, so `job.tube` for tube `email-jobs` was `"email_jobs"` and `Jobs#process!` buried its jobs; tube names that look like dates or numbers (`2026-10-30`, `1.10`) are now returned as strings instead of raising or being converted (@nesquena)
16
+ * Add `connect_timeout` (Ruby 3+), `resolv_timeout` (Ruby 3+), `read_timeout`, and `write_timeout` configuration options (@rveznaver)
17
+ * Fix `StatStruct#[]` raising a `SyntaxError` when looking up hyphenated beanstalkd keys (e.g. `stats['current-jobs-ready']`); keys are now underscored before lookup to match how they are stored (@dkam)
18
+
3
19
  ## 1.1.4 (March 6 2026)
4
20
 
5
21
  * Add frozen string literal comments for Ruby 3.4 compatibility (@nearapogee)
data/README.md CHANGED
@@ -133,11 +133,21 @@ Beaneater.configure do |config|
133
133
  # config.job_parser = lambda { |body| body }
134
134
  # config.job_serializer = lambda { |body| body }
135
135
  # config.beanstalkd_url = 'localhost:11300'
136
+ # config.connect_timeout = nil
137
+ # config.resolv_timeout = nil
138
+ # config.read_timeout = nil
139
+ # config.write_timeout = nil
136
140
  end
137
141
  ```
138
142
 
139
143
  The above options are all defaults, so only include a configuration block if you need to make changes.
140
144
 
145
+ `connect_timeout` and `resolv_timeout` are passed through to `TCPSocket.new` on Ruby 3.0 and newer (ignored on Ruby < 3.0 for compatibility). `read_timeout` bounds how long beaneater waits for beanstalkd's reply to a command; for `reserve(timeout)` it is added to the reserve's own timeout, and a plain `reserve` (no timeout) still waits as long as it takes for a job. `write_timeout` bounds how long sending a command may take. When either runs out, `Beaneater::SocketTimeout` (a `Beaneater::NotConnected`) is raised and the connection is dropped; the next command reconnects. Timeouts are in seconds; `read_timeout` and `write_timeout` may also be numeric strings (from `ENV`, say).
146
+
147
+ A `SocketTimeout` on `put` does not tell you whether beanstalkd stored the job: the command may have reached the server and only the reply been lost. Retrying the `put` can therefore enqueue the job twice, so retry a timed-out `put` only if running the job twice is harmless.
148
+
149
+ While beanstalkd is unreachable, every command tries to reconnect three times, one second apart, before raising `Beaneater::NotConnected`, so each command takes about 2 seconds to fail (longer per attempt when connecting itself hangs, as with an unreachable host). Threads sharing a client pause between their retries side by side, but the connection attempts themselves still run one at a time. Apps that enqueue jobs inside web requests should set `connect_timeout` and expect this delay while beanstalkd is down.
150
+
141
151
  ### Connection
142
152
 
143
153
  To interact with a beanstalk queue, first establish a connection by providing an address:
@@ -412,6 +422,23 @@ The `process!` command is ideally suited for a beanstalk job processing daemon.
412
422
  Even though `process!` is intended to be a long-running process, you can stop the loop at any time
413
423
  by raising `AbortProcessingError` while processing is running.
414
424
 
425
+ If the `job_parser` raises for a job (a body that isn't JSON, say), `process!` buries that job and
426
+ goes on; if it raises `AbortProcessingError`, `process!` buries the job and stops, as for a
427
+ handler's abort. The job is not buried when the parser raises `NotConnected` while this client's
428
+ connection is gone (`process!` raises it, and beanstalkd releases the job), an exception that isn't
429
+ a `StandardError` (`process!` raises it, and the job stays reserved until its TTR runs out or the
430
+ connection closes), or one that can't be copied (raised untagged, so `process!` can't tell which job
431
+ it came from: the job stays reserved and `process!` keeps failing on it; make parser exceptions
432
+ copyable). A direct `tubes.reserve` raises a copy of
433
+ the parser's error (same class, message, cause and backtrace; a frozen exception, for which Ruby
434
+ records no backtrace, gets one that starts in beaneater) that is also a
435
+ `Beaneater::JobParseFailure`: its `job_id` names the job, still reserved by your connection, so
436
+ you can bury, release or delete it.
437
+
438
+ If beanstalkd goes away and reconnecting fails, `process!` raises `Beaneater::NotConnected`.
439
+ The client reconnects on its next command, so calling `process!` again (for example from a
440
+ supervisor loop) resumes once the server is back.
441
+
415
442
  ### Handling Errors
416
443
 
417
444
  While using Beaneater, certain errors may be encountered. Errors are encountered when
@@ -424,6 +451,7 @@ are listed below:
424
451
  | Beaneater::InvalidTubeName | Specified tube name for use or watch is not valid. |
425
452
  | Beaneater::NotFoundError | Specified job or tube could not be found. |
426
453
  | Beaneater::TimedOutError | Job could not be reserved within time specified. |
454
+ | Beaneater::SocketTimeout | No reply within `read_timeout` (or the write exceeded `write_timeout`); the connection is dropped and the next command reconnects. A subclass of `NotConnected`, not to be confused with `TimedOutError`. |
427
455
  | Beaneater::JobNotReserved | Job has not been reserved and action cannot be taken. |
428
456
 
429
457
  There are other exceptions that are less common such as `OutOfMemoryError`, `DrainingError`,
data/beaneater.gemspec CHANGED
@@ -18,6 +18,7 @@ Gem::Specification.new do |gem|
18
18
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
19
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
20
  gem.require_paths = ["lib"]
21
+ gem.required_ruby_version = '>= 2.7'
21
22
  gem.add_development_dependency 'minitest', "~> 4.1.0"
22
23
  gem.add_development_dependency 'rake'
23
24
  gem.add_development_dependency 'mocha'
@@ -8,6 +8,10 @@ class Beaneater
8
8
  attr_accessor :job_parser # default job_parser to parse job body
9
9
  attr_accessor :job_serializer # default serializer for job body
10
10
  attr_accessor :beanstalkd_url # default beanstalkd url
11
+ attr_accessor :connect_timeout # TCP connect timeout in seconds
12
+ attr_accessor :resolv_timeout # DNS resolve timeout in seconds
13
+ attr_accessor :read_timeout # seconds to wait for a reply (added to reserve-with-timeout's own timeout; plain reserve is never cut off)
14
+ attr_accessor :write_timeout # seconds to wait for a command to be written
11
15
 
12
16
  def initialize
13
17
  @default_put_delay = 0
@@ -16,6 +20,10 @@ class Beaneater
16
20
  @job_parser = lambda { |body| body }
17
21
  @job_serializer = lambda { |body| body }
18
22
  @beanstalkd_url = nil
23
+ @connect_timeout = nil
24
+ @resolv_timeout = nil
25
+ @read_timeout = nil
26
+ @write_timeout = nil
19
27
  end
20
28
  end # Configuration
21
29
  end # Beaneater
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'socket'
5
+ require 'io/wait'
6
+ require 'monitor'
5
7
 
6
8
  class Beaneater
7
9
  # Represents a connection to a beanstalkd instance.
@@ -13,6 +15,15 @@ class Beaneater
13
15
  # Default retry interval
14
16
  DEFAULT_RETRY_INTERVAL = 1
15
17
 
18
+ # Fields of the stats responses that are text even when they look numeric
19
+ # (a tube named "1.12", a server id made only of digits). beanstalkd 1.13
20
+ # quotes most of these, 1.12 does not.
21
+ STRING_STAT_FIELDS = {
22
+ 'stats' => %w(version id hostname os platform),
23
+ 'stats-job' => %w(tube state),
24
+ 'stats-tube' => %w(name)
25
+ }.freeze
26
+
16
27
  # @!attribute address
17
28
  # @return [String] returns Beanstalkd server address
18
29
  # @example
@@ -52,7 +63,10 @@ class Beaneater
52
63
  #
53
64
  def initialize(address)
54
65
  @address = address || _host_from_env || Beaneater.configuration.beanstalkd_url
55
- @mutex = Mutex.new
66
+ # Held for each attempt at a command (reconnecting and restoring the tubes included), not
67
+ # across the pauses between attempts. Reentrant: a job_parser that uses this client runs
68
+ # while its command holds the lock.
69
+ @mutex = Monitor.new
56
70
  @tube_used = 'default'
57
71
  @tubes_watched = ['default']
58
72
 
@@ -72,16 +86,27 @@ class Beaneater
72
86
  # @conn.transmit('stats')
73
87
  #
74
88
  def transmit(command, **options)
75
- _with_retry(**options.slice(:retry_interval, :init)) do
76
- @mutex.synchronize do
77
- _raise_not_connected! unless connection
78
-
79
- command = command.dup.force_encoding('ASCII-8BIT') if command.respond_to?(:force_encoding)
80
- connection.write(command.to_s + "\r\n")
81
- res = connection.readline
82
- parse_response(command, res)
83
- end
89
+ state = {
90
+ :retry_interval => options[:retry_interval] || DEFAULT_RETRY_INTERVAL, # nil too
91
+ :init => options.fetch(:init, true),
92
+ :reconnects => 0,
93
+ :connect_tries => MAX_RETRIES,
94
+ :draining_tries => MAX_RETRIES,
95
+ :connecting => false
96
+ }
97
+ reentrant = @mutex.mon_owned?
98
+ loop do
99
+ done, value = @mutex.synchronize { _attempt(command, state) }
100
+ return value if done
101
+ # Pause before the next attempt with the lock released, so other threads go on and
102
+ # Timeout or Thread#raise can stop this one. A reentrant call (from a job_parser) still
103
+ # holds its outer command's lock here.
104
+ sleep(value)
84
105
  end
106
+ ensure
107
+ # #close during a command (from its job_parser, a signal handler or another thread) only
108
+ # shut the socket down; close it once the command is over and nothing waits on it
109
+ @mutex.synchronize { _drop_connection if @closed && @connection } if @closed && !reentrant
85
110
  end
86
111
 
87
112
  # Close connection with beanstalkd server.
@@ -90,10 +115,31 @@ class Beaneater
90
115
  # @conn.close
91
116
  #
92
117
  def close
93
- if @connection
94
- @connection.close
95
- @connection = nil
118
+ @closed = true
119
+ socket = @connection
120
+ if @mutex.mon_locked? && @connection_pid == Process.pid
121
+ # A thread (this one or another) is inside a command and may be waiting on the socket.
122
+ # IO#close from a signal handler while any thread waits on that IO can crash Ruby 3.3
123
+ # and 3.4 (segfault in IO#wait_readable) or raise ThreadError. Shut it down instead:
124
+ # the wait ends with end-of-file, the command raises NotConnected, and its transmit
125
+ # closes the socket on its way out. Only in the process that opened the connection: a
126
+ # child forked during a command inherits the held lock, and shutting down would cut
127
+ # the parent's connection; the child just closes its own copy below.
128
+ socket.shutdown(Socket::SHUT_RDWR) if socket && !socket.closed?
129
+ else
130
+ # ceiling: a thread can take the lock between the check above and this close and start
131
+ # waiting on the socket. A close from a signal handler then raises ThreadError on Ruby
132
+ # 3.3 and 3.4 (ignored below); the waiting thread gets a TypeError from Ruby, and Ruby
133
+ # keeps the fd open (and the server that connection's pending command) until garbage
134
+ # collection. On 3.1, 3.2 and 4.0, and from a plain thread, the waiter gets IOError.
135
+ # Closing under the lock would shut the window, but a signal handler cannot wait for
136
+ # the lock.
137
+ _drop_connection
96
138
  end
139
+ rescue ThreadError
140
+ nil # IO#close refused in a signal handler (see the ceiling above)
141
+ rescue SystemCallError, IOError
142
+ nil # already disconnected, or closed by another thread meanwhile
97
143
  end
98
144
 
99
145
  # Returns string representation of job.
@@ -129,7 +175,17 @@ class Beaneater
129
175
  match = address.split(':')
130
176
  @host, @port = match[0], Integer(match[1] || DEFAULT_PORT)
131
177
 
132
- @connection = TCPSocket.new @host, @port
178
+ tcp_opts = { connect_timeout: config.connect_timeout, resolv_timeout: config.resolv_timeout }.compact
179
+
180
+ socket = if RUBY_VERSION >= "3.0" && tcp_opts.any?
181
+ TCPSocket.new(@host, @port, **tcp_opts)
182
+ else
183
+ TCPSocket.new(@host, @port)
184
+ end
185
+
186
+ @read_buffer = String.new # binary; bytes read but not yet consumed
187
+ @connection = socket
188
+ @connection_pid = Process.pid # a forked child shares the socket; see #close
133
189
  end
134
190
 
135
191
  # Parses the response and returns the useful beanstalk response.
@@ -151,15 +207,17 @@ class Beaneater
151
207
  body = nil
152
208
  if ['OK','FOUND', 'RESERVED'].include?(status)
153
209
  bytes_size = body_values[-1].to_i
154
- raw_body = connection.read(bytes_size)
210
+ raw_body = _read_bytes(bytes_size, cmd)
211
+ # Consume the trailing CRLF before parsing, so a parser that raises
212
+ # leaves the stream at the start of the next reply.
213
+ crlf = _read_bytes(2, cmd) # \r\n
214
+ raise ExpectedCrlfError.new('EXPECTED_CRLF', cmd) if crlf != "\r\n"
215
+ @reply_read = true
155
216
  body = if status == 'OK'
156
- psych_v4_valid_body = raw_body.gsub(/^(.*?): (.*)$/) { "#{$1}: #{$2.gsub(/[\:\-\~]/, '_')}" }
157
- YAML.load(psych_v4_valid_body)
217
+ parse_yaml_body(cmd, raw_body)
158
218
  else
159
- config.job_parser.call(raw_body)
219
+ _parse_job(status, body_values[1], raw_body)
160
220
  end
161
- crlf = connection.read(2) # \r\n
162
- raise ExpectedCrlfError.new('EXPECTED_CRLF', cmd) if crlf != "\r\n"
163
221
  end
164
222
  id = body_values[1]
165
223
  response = { :status => status }
@@ -168,6 +226,57 @@ class Beaneater
168
226
  response
169
227
  end
170
228
 
229
+ # Parses the YAML body of an OK response (stats, stats-job, stats-tube,
230
+ # list-tubes, list-tubes-watched). beanstalkd emits only a "---" line
231
+ # followed by "key: value" lines or "- item" lines, so the body is parsed
232
+ # directly rather than through a YAML loader, which would turn tube names
233
+ # such as "2022-09-30", "1.12" or "true" into dates, numbers or booleans.
234
+ #
235
+ # @param [String] cmd Beanstalk command transmitted
236
+ # @param [String] raw_body Response body
237
+ # @return [Hash{String => String, Integer, Float, Boolean}, Array<String>] Parsed body
238
+ # @example
239
+ # parse_yaml_body("stats-job 1", "---\nid: 1\ntube: email-jobs\n")
240
+ # # => { "id" => 1, "tube" => "email-jobs" }
241
+ #
242
+ def parse_yaml_body(cmd, raw_body)
243
+ lines = raw_body.dup.force_encoding(Encoding::UTF_8).each_line.map(&:chomp)
244
+ lines.shift if lines.first == '---'
245
+ if lines.first && lines.first.start_with?('- ')
246
+ lines.map { |line| _stat_value(line[2..-1], true) }
247
+ else
248
+ text_fields = STRING_STAT_FIELDS[cmd.to_s.split(' ').first] || []
249
+ lines.each_with_object({}) do |line, hash|
250
+ key, value = line.split(': ', 2)
251
+ hash[key] = _stat_value(value.to_s, text_fields.include?(key)) unless key.to_s.empty?
252
+ end
253
+ end
254
+ end
255
+
256
+ # Runs the job_parser on a job's body. If it raises for a job this connection has just
257
+ # reserved, a copy of the parser's exception is raised instead (same class, message,
258
+ # backtrace and cause, so `rescue JSON::ParserError` still works), tagged with
259
+ # Beaneater::JobParseFailure, which gives the job's id: Jobs#process! uses it to bury the job
260
+ # instead of leaving it reserved until its TTR. A copy, so that an exception the parser
261
+ # froze or raises again later is never changed or tagged with an old id.
262
+ def _parse_job(status, id, raw_body)
263
+ config.job_parser.call(raw_body)
264
+ rescue StandardError => ex
265
+ raise unless status == 'RESERVED'
266
+ begin
267
+ tagged = ex.clone(:freeze => false)
268
+ tagged.extend(JobParseFailure)
269
+ tagged.job_id = id
270
+ cause = ex.cause
271
+ rescue StandardError
272
+ tagged = nil # it can't be copied (an initialize_clone or initialize_copy of its own)
273
+ end
274
+ # Re-raised out here, where $! is still ex: raised inside the rescue above, a frozen ex
275
+ # would get the copying error as its cause and fail with "circular causes".
276
+ raise ex unless tagged
277
+ raise tagged, :cause => (cause if cause.is_a?(Exception))
278
+ end
279
+
171
280
  # Returns configuration options for beaneater
172
281
  #
173
282
  # @return [Beaneater::Configuration] configuration object
@@ -177,57 +286,111 @@ class Beaneater
177
286
 
178
287
  private
179
288
 
289
+ # Sends one command and reads its whole reply, without retrying.
290
+ def _exchange(command)
291
+ completed = false
292
+ outer_reply_read = @reply_read # a job_parser that uses this client runs an _exchange inside ours
293
+ @reply_read = false # set by parse_response once the whole reply is off the socket
294
+ unless connection
295
+ _raise_not_connected! if @closed
296
+ raise Errno::ENOTCONN # _attempt reconnects and restores the tubes first
297
+ end
298
+
299
+ command = command.dup.force_encoding('ASCII-8BIT') if command.respond_to?(:force_encoding)
300
+ read_window = _read_window(command) # checked before anything is written
301
+ _write(command.to_s + "\r\n", command)
302
+ @read_deadline = _deadline_in(read_window) # bounds the status line and the body read in parse_response
303
+ res = _read_line(command)
304
+ response = parse_response(command, res)
305
+ completed = true
306
+ response
307
+ rescue UnexpectedResponse => ex
308
+ # an error status line is the whole reply, so the stream is still in step
309
+ completed = !ex.is_a?(ExpectedCrlfError)
310
+ raise
311
+ ensure
312
+ # Interrupted between the write and the end of the reply (Timeout, Interrupt, an I/O
313
+ # error): the rest of the reply may still arrive, so drop the socket rather than let the
314
+ # next command read it. The next command reconnects. Once the whole reply has been read
315
+ # (a job_parser that raised, say) the stream is in step: keep the socket, and with it the
316
+ # jobs this connection has reserved.
317
+ _drop_connection unless completed || @reply_read
318
+ @reply_read = outer_reply_read
319
+ end
320
+
321
+ # Restores the watched and used tubes on a new connection. Sent with _exchange, not transmit:
322
+ # a failure here must go back to the _attempt that is reconnecting, which starts over.
180
323
  def _initialize_tubes
181
324
  if @tubes_watched != ['default']
182
325
  tubes_watched.each do |t|
183
- transmit("watch #{t}", init: false)
326
+ _exchange("watch #{t}")
184
327
  end
185
328
 
186
- transmit("ignore default", init: false)
329
+ _exchange("ignore default") unless tubes_watched.include?('default')
187
330
  end
188
331
 
189
- transmit("use #{tube_used}", init: false) if @tube_used != 'default'
332
+ _exchange("use #{tube_used}") if @tube_used != 'default'
190
333
  end
191
334
 
192
- # Wrapper method for capturing certain failures and retry the payload block
193
- #
194
- # @param [Proc] block The command to execute.
195
- # @param [Integer] retry_interval The time to wait before the next retry
196
- # @param [Integer] tries The maximum number of tries in draining mode
197
- # @return [Object] Result of the block passed
198
- #
199
- def _with_retry(retry_interval: DEFAULT_RETRY_INTERVAL, init: true, tries: MAX_RETRIES, &block)
200
- yield
201
- rescue EOFError, Errno::ECONNRESET, Errno::EPIPE,
202
- Errno::ECONNREFUSED => ex
203
- _reconnect(ex, retry_interval)
204
- _initialize_tubes if init
205
- retry
335
+ # One attempt at a command, made holding the lock: reconnect (and restore the tubes) if the
336
+ # connection is gone, then send the command. Returns [true, response], or [false, seconds]
337
+ # to pause and try again. state carries the retry counts across attempts.
338
+ def _attempt(command, state)
339
+ if connection
340
+ state[:connecting] = false # another thread may have reconnected during our pause
341
+ else
342
+ _raise_not_connected! if @closed
343
+ unless state[:connecting] # a new reconnect, not the next try of one in progress
344
+ state[:reconnects] += 1
345
+ # something accepts connections but drops every command (a proxy with no backend):
346
+ # give up rather than reconnect in a tight loop
347
+ _raise_not_connected! if state[:reconnects] > MAX_RETRIES
348
+ state[:connecting] = true
349
+ state[:connect_tries] = MAX_RETRIES
350
+ end
351
+ begin
352
+ establish_connection
353
+ rescue SystemCallError, SocketError, IOError
354
+ state[:connect_tries] -= 1
355
+ _raise_not_connected! if state[:connect_tries].zero?
356
+ return [false, state[:retry_interval]]
357
+ end
358
+ state[:connecting] = false
359
+ if @closed # #close ran while connecting
360
+ _drop_connection
361
+ _raise_not_connected!
362
+ end
363
+ if state[:init]
364
+ begin
365
+ _initialize_tubes
366
+ rescue Exception
367
+ # never leave a half-restored connection watching the wrong tubes: drop it, so a
368
+ # lost connection reconnects and restores again below, and anything else makes the
369
+ # next command start over
370
+ _drop_connection
371
+ raise
372
+ end
373
+ end
374
+ end
375
+ [true, _exchange(command)]
376
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED, Errno::ENOTCONN
377
+ _drop_connection
378
+ retry # reconnects at once, still holding the lock
206
379
  rescue Beaneater::DrainingError
207
- tries -= 1
208
- if tries.zero?
209
- close
380
+ state[:draining_tries] -= 1
381
+ if state[:draining_tries].zero?
382
+ _drop_connection
210
383
  raise
211
384
  end
212
- sleep(retry_interval)
213
- retry
385
+ [false, state[:retry_interval]]
214
386
  end
215
387
 
216
- # Tries to re-establish connection to the beanstalkd
217
- #
218
- # @param [Exception] original_exception The exception caused the retry
219
- # @param [Integer] retry_interval The time to wait before the next reconnect
220
- # @param [Integer] tries The maximum number of attempts to reconnect
221
- def _reconnect(original_exception, retry_interval, tries=MAX_RETRIES)
222
- close
223
- establish_connection
224
- rescue Errno::ECONNREFUSED
225
- tries -= 1
226
- if tries.zero?
227
- _raise_not_connected!
228
- end
229
- sleep(retry_interval || DEFAULT_RETRY_INTERVAL)
230
- retry
388
+ # Closes the socket without marking the connection closed, so the next command reconnects.
389
+ # Only #close is final.
390
+ def _drop_connection
391
+ socket, @connection = @connection, nil
392
+ @read_buffer = String.new
393
+ socket.close if socket
231
394
  end
232
395
 
233
396
  # The host provided by BEANSTALKD_URL environment variable, if available.
@@ -241,6 +404,99 @@ class Beaneater
241
404
  ENV['BEANSTALKD_URL'].respond_to?(:length) && ENV['BEANSTALKD_URL'].length > 0 && ENV['BEANSTALKD_URL'].strip
242
405
  end
243
406
 
407
+ # Converts one value of a stats or list response to its Ruby type:
408
+ # quoted values and text fields stay Strings, whole numbers become
409
+ # Integers, decimals Floats, and true/false Booleans.
410
+ def _stat_value(value, text)
411
+ if value.length >= 2 && value.start_with?('"') && value.end_with?('"')
412
+ value[1..-2]
413
+ elsif text
414
+ value
415
+ elsif value =~ /\A-?\d+\z/
416
+ value.to_i
417
+ elsif value =~ /\A-?\d+\.\d+\z/
418
+ value.to_f
419
+ elsif value == 'true' || value == 'false'
420
+ value == 'true'
421
+ else
422
+ value
423
+ end
424
+ end
425
+
426
+ # Writes data to the socket, waiting at most write_timeout seconds.
427
+ #
428
+ # Ruby waits on its sockets itself, so the socket-level SO_SNDTIMEO and
429
+ # SO_RCVTIMEO options have no effect; the deadlines are kept here instead.
430
+ def _write(data, cmd)
431
+ deadline = _deadline_in(config.write_timeout && Float(config.write_timeout))
432
+ until data.empty?
433
+ written = connection.write_nonblock(data, exception: false)
434
+ if written == :wait_writable
435
+ _wait_for(:write, deadline, cmd)
436
+ else
437
+ data = data.byteslice(written..-1)
438
+ end
439
+ end
440
+ end
441
+
442
+ # Returns how many seconds the reply to cmd may take once it is written, or
443
+ # nil to wait without limit. reserve-with-timeout gets its own timeout on top
444
+ # of read_timeout; a plain reserve blocks until a job arrives, by design.
445
+ # Timeouts may be given as numeric strings (from ENV, say).
446
+ def _read_window(cmd)
447
+ return nil if config.read_timeout.nil? || cmd =~ /\Areserve\s*\z/
448
+ extra = cmd =~ /\Areserve-with-timeout (\d+)/ ? $1.to_i : 0
449
+ Float(config.read_timeout) + extra
450
+ end
451
+
452
+ # Reads one reply line (the status line), without its line ending.
453
+ def _read_line(cmd)
454
+ until (eol = @read_buffer.index("\n"))
455
+ _fill_read_buffer(cmd)
456
+ end
457
+ @read_buffer.slice!(0, eol + 1)
458
+ end
459
+
460
+ # Reads exactly size bytes of a reply body.
461
+ def _read_bytes(size, cmd)
462
+ _fill_read_buffer(cmd) while @read_buffer.bytesize < size
463
+ @read_buffer.slice!(0, size)
464
+ end
465
+
466
+ # Waits until the socket has data (up to the reply's deadline) and appends
467
+ # what is available to the read buffer.
468
+ def _fill_read_buffer(cmd)
469
+ _wait_for(:read, @read_deadline, cmd)
470
+ @read_buffer << connection.readpartial(16_384)
471
+ end
472
+
473
+ # Waits for the socket to become readable or writable before deadline.
474
+ # On timeout the connection is dropped (the next command reconnects): a late
475
+ # reply would otherwise be read as the answer to the next command.
476
+ #
477
+ # @raise [Beaneater::SocketTimeout] The deadline passed.
478
+ def _wait_for(direction, deadline, cmd)
479
+ # Past the deadline this still polls once (timeout 0): data that is already there is
480
+ # not a timeout.
481
+ remaining = deadline && [deadline - _now, 0].max
482
+ # IO#wait_* rather than IO.select: a thread waiting here is woken by end-of-file when
483
+ # #close shuts the socket down (its command then raises NotConnected).
484
+ ready = direction == :read ? connection.wait_readable(remaining) : connection.wait_writable(remaining)
485
+ return if ready
486
+
487
+ _drop_connection
488
+ action = direction == :read ? 'waiting for the reply to' : 'writing'
489
+ raise SocketTimeout, "Timed out #{action} '#{cmd.to_s.split("\r\n").first}' on beanstalk '#{@host}:#{@port}'; the next command reconnects"
490
+ end
491
+
492
+ def _deadline_in(seconds)
493
+ seconds && _now + seconds
494
+ end
495
+
496
+ def _now
497
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
498
+ end
499
+
244
500
  # Raises an error to be triggered when the connection has failed
245
501
  # @raise [Beaneater::NotConnected] Beanstalkd is no longer connected
246
502
  def _raise_not_connected!
@@ -3,6 +3,20 @@
3
3
  class Beaneater
4
4
  # Raises when a beanstalkd instance is no longer accessible.
5
5
  class NotConnected < RuntimeError; end
6
+ # Raises when beanstalkd does not answer within the configured read_timeout,
7
+ # or a command cannot be written within write_timeout. The connection is
8
+ # closed, since a late reply would otherwise be read as the answer to the
9
+ # next command. Not to be confused with {TimedOutError}, which is
10
+ # beanstalkd's own TIMED_OUT reply to reserve-with-timeout.
11
+ class SocketTimeout < NotConnected; end
12
+ # Added to a copy of the exception a job_parser raises for a job just reserved. The copy
13
+ # keeps the exception's class (JSON::ParserError, say), message, backtrace and cause; #job_id
14
+ # names the job, still reserved by this connection, so the caller can bury, release or
15
+ # delete it.
16
+ module JobParseFailure
17
+ # @return [String] id of the reserved job whose body the parser rejected
18
+ attr_accessor :job_id
19
+ end
6
20
  # Raises when the tube name specified is invalid.
7
21
  class InvalidTubeName < RuntimeError; end
8
22
  # Raises when a job has not been reserved properly.