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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +44 -0
- data/CHANGELOG.md +23 -0
- data/Gemfile +2 -0
- data/README.md +29 -1
- data/Rakefile +2 -0
- data/beaneater.gemspec +2 -0
- data/examples/demo.rb +2 -0
- data/lib/beaneater/configuration.rb +10 -0
- data/lib/beaneater/connection.rb +315 -57
- data/lib/beaneater/errors.rb +16 -0
- data/lib/beaneater/job/collection.rb +44 -6
- data/lib/beaneater/job/record.rb +2 -0
- data/lib/beaneater/job.rb +2 -0
- data/lib/beaneater/stats/fast_struct.rb +2 -0
- data/lib/beaneater/stats/stat_struct.rb +8 -1
- data/lib/beaneater/stats.rb +2 -0
- data/lib/beaneater/tube/collection.rb +2 -0
- data/lib/beaneater/tube/record.rb +2 -0
- data/lib/beaneater/tube.rb +2 -0
- data/lib/beaneater/version.rb +3 -1
- data/lib/beaneater.rb +2 -0
- data/test/beaneater_test.rb +2 -0
- data/test/connection_test.rb +612 -12
- data/test/errors_test.rb +2 -0
- data/test/job_test.rb +14 -0
- data/test/jobs_test.rb +431 -0
- data/test/prompt_regexp_test.rb +2 -0
- data/test/stat_struct_test.rb +7 -0
- data/test/stats_test.rb +2 -0
- data/test/test_helper.rb +47 -0
- data/test/tube_test.rb +125 -0
- data/test/tubes_test.rb +18 -1
- metadata +5 -4
data/lib/beaneater/connection.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
155
|
-
YAML.load(psych_v4_valid_body)
|
|
217
|
+
parse_yaml_body(cmd, raw_body)
|
|
156
218
|
else
|
|
157
|
-
|
|
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
|
-
|
|
326
|
+
_exchange("watch #{t}")
|
|
182
327
|
end
|
|
183
328
|
|
|
184
|
-
|
|
329
|
+
_exchange("ignore default") unless tubes_watched.include?('default')
|
|
185
330
|
end
|
|
186
331
|
|
|
187
|
-
|
|
332
|
+
_exchange("use #{tube_used}") if @tube_used != 'default'
|
|
188
333
|
end
|
|
189
334
|
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
if
|
|
207
|
-
|
|
380
|
+
state[:draining_tries] -= 1
|
|
381
|
+
if state[:draining_tries].zero?
|
|
382
|
+
_drop_connection
|
|
208
383
|
raise
|
|
209
384
|
end
|
|
210
|
-
|
|
211
|
-
retry
|
|
385
|
+
[false, state[:retry_interval]]
|
|
212
386
|
end
|
|
213
387
|
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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!
|
data/lib/beaneater/errors.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
data/lib/beaneater/job/record.rb
CHANGED
data/lib/beaneater/job.rb
CHANGED
|
@@ -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
|
data/lib/beaneater/stats.rb
CHANGED
data/lib/beaneater/tube.rb
CHANGED
data/lib/beaneater/version.rb
CHANGED
data/lib/beaneater.rb
CHANGED