beaneater 1.1.3 → 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.
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'yaml'
2
4
  require 'socket'
5
+ require 'io/wait'
6
+ require 'monitor'
3
7
 
4
8
  class Beaneater
5
9
  # Represents a connection to a beanstalkd instance.
@@ -11,6 +15,15 @@ class Beaneater
11
15
  # Default retry interval
12
16
  DEFAULT_RETRY_INTERVAL = 1
13
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
+
14
27
  # @!attribute address
15
28
  # @return [String] returns Beanstalkd server address
16
29
  # @example
@@ -50,7 +63,10 @@ class Beaneater
50
63
  #
51
64
  def initialize(address)
52
65
  @address = address || _host_from_env || Beaneater.configuration.beanstalkd_url
53
- @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
54
70
  @tube_used = 'default'
55
71
  @tubes_watched = ['default']
56
72
 
@@ -70,16 +86,27 @@ class Beaneater
70
86
  # @conn.transmit('stats')
71
87
  #
72
88
  def transmit(command, **options)
73
- _with_retry(**options.slice(:retry_interval, :init)) do
74
- @mutex.synchronize do
75
- _raise_not_connected! unless connection
76
-
77
- command = command.force_encoding('ASCII-8BIT') if command.respond_to?(:force_encoding)
78
- connection.write(command.to_s + "\r\n")
79
- res = connection.readline
80
- parse_response(command, res)
81
- 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)
82
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
83
110
  end
84
111
 
85
112
  # Close connection with beanstalkd server.
@@ -88,10 +115,31 @@ class Beaneater
88
115
  # @conn.close
89
116
  #
90
117
  def close
91
- if @connection
92
- @connection.close
93
- @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
94
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
95
143
  end
96
144
 
97
145
  # Returns string representation of job.
@@ -127,7 +175,17 @@ class Beaneater
127
175
  match = address.split(':')
128
176
  @host, @port = match[0], Integer(match[1] || DEFAULT_PORT)
129
177
 
130
- @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
131
189
  end
132
190
 
133
191
  # Parses the response and returns the useful beanstalk response.
@@ -149,15 +207,17 @@ class Beaneater
149
207
  body = nil
150
208
  if ['OK','FOUND', 'RESERVED'].include?(status)
151
209
  bytes_size = body_values[-1].to_i
152
- 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
153
216
  body = if status == 'OK'
154
- psych_v4_valid_body = raw_body.gsub(/^(.*?): (.*)$/) { "#{$1}: #{$2.gsub(/[\:\-\~]/, '_')}" }
155
- YAML.load(psych_v4_valid_body)
217
+ parse_yaml_body(cmd, raw_body)
156
218
  else
157
- config.job_parser.call(raw_body)
219
+ _parse_job(status, body_values[1], raw_body)
158
220
  end
159
- crlf = connection.read(2) # \r\n
160
- raise ExpectedCrlfError.new('EXPECTED_CRLF', cmd) if crlf != "\r\n"
161
221
  end
162
222
  id = body_values[1]
163
223
  response = { :status => status }
@@ -166,6 +226,57 @@ class Beaneater
166
226
  response
167
227
  end
168
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
+
169
280
  # Returns configuration options for beaneater
170
281
  #
171
282
  # @return [Beaneater::Configuration] configuration object
@@ -175,57 +286,111 @@ class Beaneater
175
286
 
176
287
  private
177
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.
178
323
  def _initialize_tubes
179
324
  if @tubes_watched != ['default']
180
325
  tubes_watched.each do |t|
181
- transmit("watch #{t}", init: false)
326
+ _exchange("watch #{t}")
182
327
  end
183
328
 
184
- transmit("ignore default", init: false)
329
+ _exchange("ignore default") unless tubes_watched.include?('default')
185
330
  end
186
331
 
187
- transmit("use #{tube_used}", init: false) if @tube_used != 'default'
332
+ _exchange("use #{tube_used}") if @tube_used != 'default'
188
333
  end
189
334
 
190
- # Wrapper method for capturing certain failures and retry the payload block
191
- #
192
- # @param [Proc] block The command to execute.
193
- # @param [Integer] retry_interval The time to wait before the next retry
194
- # @param [Integer] tries The maximum number of tries in draining mode
195
- # @return [Object] Result of the block passed
196
- #
197
- def _with_retry(retry_interval: DEFAULT_RETRY_INTERVAL, init: true, tries: MAX_RETRIES, &block)
198
- yield
199
- rescue EOFError, Errno::ECONNRESET, Errno::EPIPE,
200
- Errno::ECONNREFUSED => ex
201
- _reconnect(ex, retry_interval)
202
- _initialize_tubes if init
203
- 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
204
379
  rescue Beaneater::DrainingError
205
- tries -= 1
206
- if tries.zero?
207
- close
380
+ state[:draining_tries] -= 1
381
+ if state[:draining_tries].zero?
382
+ _drop_connection
208
383
  raise
209
384
  end
210
- sleep(retry_interval)
211
- retry
385
+ [false, state[:retry_interval]]
212
386
  end
213
387
 
214
- # Tries to re-establish connection to the beanstalkd
215
- #
216
- # @param [Exception] original_exception The exception caused the retry
217
- # @param [Integer] retry_interval The time to wait before the next reconnect
218
- # @param [Integer] tries The maximum number of attempts to reconnect
219
- def _reconnect(original_exception, retry_interval, tries=MAX_RETRIES)
220
- close
221
- establish_connection
222
- rescue Errno::ECONNREFUSED
223
- tries -= 1
224
- if tries.zero?
225
- _raise_not_connected!
226
- end
227
- sleep(retry_interval || DEFAULT_RETRY_INTERVAL)
228
- 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
229
394
  end
230
395
 
231
396
  # The host provided by BEANSTALKD_URL environment variable, if available.
@@ -239,6 +404,99 @@ class Beaneater
239
404
  ENV['BEANSTALKD_URL'].respond_to?(:length) && ENV['BEANSTALKD_URL'].length > 0 && ENV['BEANSTALKD_URL'].strip
240
405
  end
241
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
+
242
500
  # Raises an error to be triggered when the connection has failed
243
501
  # @raise [Beaneater::NotConnected] Beanstalkd is no longer connected
244
502
  def _raise_not_connected!
@@ -1,6 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Raises when a beanstalkd instance is no longer accessible.
3
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
4
20
  # Raises when the tube name specified is invalid.
5
21
  class InvalidTubeName < RuntimeError; end
6
22
  # Raises when a job has not been reserved properly.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Exception to stop processing jobs during a `process!` loop.
3
5
  # Simply `raise AbortProcessingError` in any job process handler to stop the processing loop.
@@ -109,7 +111,20 @@ class Beaneater
109
111
  client.tubes.watch!(*processors.keys)
110
112
  while !stop? do
111
113
  begin
112
- @current_job = client.tubes.reserve(reserve_timeout)
114
+ @current_job = nil # never take an earlier iteration's job for this one
115
+ begin
116
+ @current_job = client.tubes.reserve(reserve_timeout)
117
+ rescue Beaneater::JobParseFailure => ex
118
+ # The job_parser rejected the job just reserved, so there was no job to handle: it is
119
+ # ours to bury (the ensure below does), whatever the parser raised, rather than leave
120
+ # it reserved to be released and fail again when its TTR runs out.
121
+ # ceiling: a parser exception that can't be copied is raised untagged, so process!
122
+ # can't tell which job it came from; the job stays reserved, and near its TTR process!
123
+ # keeps reserving (DEADLINE_SOON) until beanstalkd releases it, then fails on it again.
124
+ # Upgrade path: have the connection record the id of the job whose parse failed.
125
+ @current_job = Job.new(client, :id => ex.job_id, :status => 'RESERVED')
126
+ raise
127
+ end
113
128
  processor = processors[@current_job.tube]
114
129
  begin
115
130
  processor[:block].call(@current_job)
@@ -122,14 +137,37 @@ class Beaneater
122
137
  rescue AbortProcessingError
123
138
  break
124
139
  rescue Beaneater::JobNotReserved, Beaneater::NotFoundError, Beaneater::TimedOutError
125
- retry
126
- rescue StandardError # handles unspecified errors
127
- @current_job.bury if @current_job
140
+ # No job yet (reserve timed out), or the job is gone (its handler deleted or released
141
+ # it, or its TTR ran out). If a handler or the job_parser raised this with the job still
142
+ # reserved, the ensure buries that job like any failure; then the loop goes on.
143
+ nil
144
+ rescue Beaneater::NotConnected
145
+ # Only when this client's own connection is gone (reconnecting failed, or it was
146
+ # closed): the server releases a job reserved on it. A NotConnected from a handler that
147
+ # uses another client is a failed job like any other, buried by the ensure below.
148
+ unless client.connection.connection
149
+ @current_job = nil
150
+ raise
151
+ end
152
+ rescue StandardError # handles unspecified errors: the ensure below buries the job
128
153
  ensure # bury if still reserved
129
- @current_job.bury if @current_job && @current_job.exists? && @current_job.reserved?
130
- @current_job = nil
154
+ begin
155
+ bury_if_reserved(@current_job)
156
+ ensure
157
+ @current_job = nil # even when burying fails, so no later loop sees this job
158
+ end
131
159
  end
132
160
  end
133
161
  end # process!
162
+
163
+ private
164
+
165
+ # Buries a job this worker still holds. NOT_FOUND means it no longer holds it (the handler
166
+ # deleted it, or its TTR ran out and the server released it), so there is nothing to bury.
167
+ def bury_if_reserved(job)
168
+ job.bury if job && job.exists? && job.reserved?
169
+ rescue Beaneater::NotFoundError, Beaneater::JobNotReserved
170
+ nil # gone, or no longer reserved by us by the time bury ran
171
+ end
134
172
  end # Jobs
135
173
  end # Beaneater
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Represents job related commands.
3
5
  class Job
data/lib/beaneater/job.rb CHANGED
@@ -1,2 +1,4 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'beaneater/job/record'
2
4
  require 'beaneater/job/collection'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  #
3
5
  # Borrowed from:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Represents a stats hash with proper underscored keys
3
5
  class StatStruct < FasterOpenStruct
@@ -17,13 +19,18 @@ class Beaneater
17
19
 
18
20
  # Access value for stat with specified key.
19
21
  #
22
+ # Keys are stored underscored (see {.from_hash}), so hyphenated beanstalkd
23
+ # names like "current-jobs-ready" are underscored before lookup. This also
24
+ # avoids defining an invalid, hyphenated method name via method_missing.
25
+ #
20
26
  # @param [String] key Key to fetch from stats.
21
27
  # @return [String, Integer] Value for specified stat key.
22
28
  # @example
23
29
  # @stats['foo'] # => "bar"
30
+ # @stats['current-jobs-ready'] # => 5
24
31
  #
25
32
  def [](key)
26
- self.send(key.to_s)
33
+ self.send(key.to_s.gsub(/-/, '_'))
27
34
  end
28
35
 
29
36
  # Returns set of keys within this struct
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'beaneater/stats/fast_struct'
2
4
  require 'beaneater/stats/stat_struct'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Represents collection of tube related commands.
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Beanstalk tube which contains jobs which can be inserted, reserved, et al.
3
5
  class Tube
@@ -1,2 +1,4 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'beaneater/tube/record'
2
4
  require 'beaneater/tube/collection'
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Beaneater
2
4
  # Current version of gem.
3
- VERSION = "1.1.3"
5
+ VERSION = "1.2.0"
4
6
  end
data/lib/beaneater.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thread' unless defined?(Mutex)
2
4
 
3
5
  %w(version configuration errors connection tube job stats).each do |f|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require File.expand_path('../test_helper', __FILE__)
2
4
 
3
5
  describe "beanstalk-client" do