tuber 0.0.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 +4 -4
- data/.github/workflows/ruby.yml +68 -0
- data/.gitignore +17 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +134 -0
- data/Gemfile +18 -0
- data/LICENSE.txt +27 -0
- data/README.md +430 -8
- data/Rakefile +35 -0
- data/examples/demo.rb +97 -0
- data/lib/tuber/configuration.rb +49 -0
- data/lib/tuber/connection.rb +500 -0
- data/lib/tuber/errors.rb +78 -0
- data/lib/tuber/job/collection.rb +160 -0
- data/lib/tuber/job/record.rb +226 -0
- data/lib/tuber/job.rb +4 -0
- data/lib/tuber/stats/fast_struct.rb +98 -0
- data/lib/tuber/stats/stat_struct.rb +52 -0
- data/lib/tuber/stats.rb +81 -0
- data/lib/tuber/tube/collection.rb +257 -0
- data/lib/tuber/tube/record.rb +231 -0
- data/lib/tuber/tube.rb +4 -0
- data/lib/tuber/version.rb +6 -0
- data/lib/tuber.rb +125 -2
- data/test/connection_test.rb +627 -0
- data/test/errors_test.rb +35 -0
- data/test/job_test.rb +250 -0
- data/test/jobs_test.rb +152 -0
- data/test/prompt_regexp_test.rb +59 -0
- data/test/stat_struct_test.rb +58 -0
- data/test/stats_test.rb +45 -0
- data/test/test_helper.rb +74 -0
- data/test/tube_test.rb +299 -0
- data/test/tuber_test.rb +112 -0
- data/test/tubes_test.rb +342 -0
- data/tuber.gemspec +33 -0
- metadata +111 -6
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
class Tuber
|
|
7
|
+
# Represents a connection to a beanstalkd instance.
|
|
8
|
+
class Connection
|
|
9
|
+
|
|
10
|
+
# Default number of retries to send a command to a connection
|
|
11
|
+
MAX_RETRIES = 3
|
|
12
|
+
|
|
13
|
+
# Default retry interval
|
|
14
|
+
DEFAULT_RETRY_INTERVAL = 1
|
|
15
|
+
|
|
16
|
+
# @!attribute address
|
|
17
|
+
# @return [String] returns Beanstalkd server address
|
|
18
|
+
# @example
|
|
19
|
+
# @conn.address # => "localhost:11300"
|
|
20
|
+
# @!attribute host
|
|
21
|
+
# @return [String] returns Beanstalkd server host
|
|
22
|
+
# @example
|
|
23
|
+
# @conn.host # => "localhost"
|
|
24
|
+
# @!attribute port
|
|
25
|
+
# @return [Integer] returns Beanstalkd server port
|
|
26
|
+
# @example
|
|
27
|
+
# @conn.port # => "11300"
|
|
28
|
+
# @!attribute connection
|
|
29
|
+
# @return [Net::TCPSocket] returns connection object
|
|
30
|
+
attr_reader :address, :host, :port, :connection
|
|
31
|
+
|
|
32
|
+
# @!attribute tubes_watched
|
|
33
|
+
# @returns [Array<String>] returns currently watched tube names
|
|
34
|
+
# @!attribute tube_used
|
|
35
|
+
# @returns [String] returns currently used tube name
|
|
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
|
|
47
|
+
|
|
48
|
+
# Default port value for beanstalk connection
|
|
49
|
+
DEFAULT_PORT = 11300
|
|
50
|
+
|
|
51
|
+
# Commands that must not be retransmitted after a dropped connection.
|
|
52
|
+
# The socket dying between write and readline leaves the first send's
|
|
53
|
+
# fate unknown: a re-sent put can insert a duplicate job, and a re-sent
|
|
54
|
+
# delete/release/bury/touch acts on a job whose reservation died with
|
|
55
|
+
# the old socket — the server answers NOT_FOUND for work that actually
|
|
56
|
+
# succeeded. For these verbs the connection is healed but the original
|
|
57
|
+
# error is re-raised so the caller decides. Everything else (reserve,
|
|
58
|
+
# watch, stats, peek, ...) converges to the same state on a re-send and
|
|
59
|
+
# keeps the transparent retry.
|
|
60
|
+
NON_IDEMPOTENT_COMMANDS = %w[put delete delete-batch release bury touch touch-all kick kick-job].freeze
|
|
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
|
+
|
|
81
|
+
# Initializes new connection.
|
|
82
|
+
#
|
|
83
|
+
# @param [String] address beanstalkd instance address.
|
|
84
|
+
# @example
|
|
85
|
+
# Tuber::Connection.new('127.0.0.1')
|
|
86
|
+
# Tuber::Connection.new('127.0.0.1:11300')
|
|
87
|
+
#
|
|
88
|
+
# ENV['TUBER_URL'] = '127.0.0.1:11300'
|
|
89
|
+
# @b = Tuber.new
|
|
90
|
+
# @b.connection.host # => '127.0.0.1'
|
|
91
|
+
# @b.connection.port # => '11300'
|
|
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
|
+
#
|
|
96
|
+
def initialize(address)
|
|
97
|
+
@address = address || _host_from_env || Tuber.configuration.tuber_url
|
|
98
|
+
@mutex = Mutex.new
|
|
99
|
+
@tube_used = 'default'
|
|
100
|
+
@tubes_watched = ['default']
|
|
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)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Send commands to beanstalkd server via connection.
|
|
113
|
+
#
|
|
114
|
+
# @param [Hash{String => String, Number}>] options Retained for compatibility
|
|
115
|
+
# @param [String] command Beanstalkd command
|
|
116
|
+
# @return [Array<Hash{String => String, Number}>] Beanstalkd command response
|
|
117
|
+
# @example
|
|
118
|
+
# @conn = Tuber::Connection.new
|
|
119
|
+
# @conn.transmit('bury 123')
|
|
120
|
+
# @conn.transmit('stats')
|
|
121
|
+
#
|
|
122
|
+
def transmit(command, **options)
|
|
123
|
+
verb = command.to_s[/\A\S+/]
|
|
124
|
+
retransmit = !NON_IDEMPOTENT_COMMANDS.include?(verb)
|
|
125
|
+
_with_retry(retransmit: retransmit, **options.slice(:retry_interval, :init)) do
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
_raise_not_connected! unless connection
|
|
128
|
+
|
|
129
|
+
command = command.dup.force_encoding('ASCII-8BIT') if command.respond_to?(:force_encoding)
|
|
130
|
+
connection.write(command.to_s + "\r\n")
|
|
131
|
+
res = connection.readline
|
|
132
|
+
parse_response(command, res)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Reserves a batch of jobs atomically.
|
|
138
|
+
#
|
|
139
|
+
# Without a +timeout+ (or with +timeout+ of 0) the command is non-blocking:
|
|
140
|
+
# it returns whatever is ready immediately, possibly an empty array. With a
|
|
141
|
+
# positive +timeout+ it long-polls, blocking until the first job arrives (up
|
|
142
|
+
# to +timeout+ seconds) and then draining everything ready, up to +count+.
|
|
143
|
+
#
|
|
144
|
+
# @param [Integer] count Maximum number of jobs to reserve
|
|
145
|
+
# @param [Integer] timeout Seconds to long-poll for the first job (nil = non-blocking)
|
|
146
|
+
# @return [Array<Hash>] Array of job hashes with :status, :id, :body keys
|
|
147
|
+
# @raise [Tuber::DeadlineSoonError] A reserved job's TTR is about to expire
|
|
148
|
+
#
|
|
149
|
+
def reserve_batch(count, timeout = nil)
|
|
150
|
+
_with_retry do
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
_raise_not_connected! unless connection
|
|
153
|
+
|
|
154
|
+
cmd = timeout ? "reserve-batch #{count} #{timeout}" : "reserve-batch #{count}"
|
|
155
|
+
connection.write(cmd + "\r\n")
|
|
156
|
+
|
|
157
|
+
header = connection.readline.chomp
|
|
158
|
+
status, actual_count_str = header.split(/\s/, 2)
|
|
159
|
+
|
|
160
|
+
raise UnexpectedResponse.from_status(status, cmd) unless status == "RESERVED_BATCH"
|
|
161
|
+
|
|
162
|
+
actual_count = actual_count_str.to_i
|
|
163
|
+
jobs = []
|
|
164
|
+
actual_count.times do
|
|
165
|
+
line = connection.readline.chomp
|
|
166
|
+
_, job_id, bytes_str = line.split(/\s/)
|
|
167
|
+
bytes = bytes_str.to_i
|
|
168
|
+
body = connection.read(bytes)
|
|
169
|
+
crlf = connection.read(2)
|
|
170
|
+
raise ExpectedCrlfError.new("EXPECTED_CRLF", cmd) unless crlf == "\r\n"
|
|
171
|
+
|
|
172
|
+
body = config.job_parser.call(body)
|
|
173
|
+
jobs << { status: "RESERVED", id: job_id, body: body }
|
|
174
|
+
end
|
|
175
|
+
jobs
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Deletes a batch of jobs atomically.
|
|
181
|
+
#
|
|
182
|
+
# @param [Array<Integer, String>] ids Job IDs to delete
|
|
183
|
+
# @return [Hash] with :deleted and :not_found counts
|
|
184
|
+
#
|
|
185
|
+
def delete_batch(ids)
|
|
186
|
+
_with_retry(retransmit: false) do
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
_raise_not_connected! unless connection
|
|
189
|
+
|
|
190
|
+
cmd = "delete-batch #{ids.join(' ')}"
|
|
191
|
+
connection.write(cmd + "\r\n")
|
|
192
|
+
|
|
193
|
+
res = connection.readline.chomp
|
|
194
|
+
status, deleted, not_found = res.split(/\s/)
|
|
195
|
+
|
|
196
|
+
raise UnexpectedResponse.from_status(status, cmd) unless status == "DELETED_BATCH"
|
|
197
|
+
|
|
198
|
+
{ deleted: deleted.to_i, not_found: not_found.to_i }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Close connection with beanstalkd server.
|
|
204
|
+
#
|
|
205
|
+
# @example
|
|
206
|
+
# @conn.close
|
|
207
|
+
#
|
|
208
|
+
def close
|
|
209
|
+
if @connection
|
|
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
|
|
217
|
+
@connection = nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
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
|
+
|
|
254
|
+
# Returns string representation of job.
|
|
255
|
+
#
|
|
256
|
+
# @example
|
|
257
|
+
# @conn.inspect
|
|
258
|
+
#
|
|
259
|
+
def to_s
|
|
260
|
+
"#<Tuber::Connection host=#{host.inspect} port=#{port.inspect}>"
|
|
261
|
+
end
|
|
262
|
+
alias :inspect :to_s
|
|
263
|
+
|
|
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)
|
|
269
|
+
@tubes_watched << tube_name
|
|
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
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def remove_from_watched(tube_name)
|
|
280
|
+
@tube_weights.delete(tube_name)
|
|
281
|
+
@tubes_watched.delete(tube_name)
|
|
282
|
+
end
|
|
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
|
+
|
|
292
|
+
protected
|
|
293
|
+
|
|
294
|
+
# Establish a connection based on beanstalk address.
|
|
295
|
+
#
|
|
296
|
+
# @return [Net::TCPSocket] connection for specified address.
|
|
297
|
+
# @raise [Tuber::NotConnected] Could not connect to specified beanstalkd instance.
|
|
298
|
+
# @example
|
|
299
|
+
# establish_connection('localhost:3005')
|
|
300
|
+
#
|
|
301
|
+
def establish_connection
|
|
302
|
+
@address = address.first if address.is_a?(Array)
|
|
303
|
+
match = address.split(':')
|
|
304
|
+
@host, @port = match[0], Integer(match[1] || DEFAULT_PORT)
|
|
305
|
+
|
|
306
|
+
tcp_opts = { connect_timeout: config.connect_timeout, resolv_timeout: config.resolv_timeout }.compact
|
|
307
|
+
|
|
308
|
+
socket = if RUBY_VERSION >= "3.0" && tcp_opts.any?
|
|
309
|
+
TCPSocket.new(@host, @port, **tcp_opts)
|
|
310
|
+
else
|
|
311
|
+
TCPSocket.new(@host, @port)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
begin
|
|
315
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, _timeval_for(config.read_timeout)) if config.read_timeout
|
|
316
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, _timeval_for(config.write_timeout)) if config.write_timeout
|
|
317
|
+
@connection = socket
|
|
318
|
+
rescue
|
|
319
|
+
socket.close rescue nil
|
|
320
|
+
raise
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Parses the response and returns the useful beanstalk response.
|
|
325
|
+
# Will read the body if one is indicated by the status.
|
|
326
|
+
#
|
|
327
|
+
# @param [String] cmd Beanstalk command transmitted
|
|
328
|
+
# @param [String] res Telnet command response
|
|
329
|
+
# @return [Array<Hash{String => String, Number}>] Beanstalk response with `status`, `id`, `body`
|
|
330
|
+
# @raise [Tuber::UnexpectedResponse] Response from beanstalk command was an error status
|
|
331
|
+
# @example
|
|
332
|
+
# parse_response("delete 56", "DELETED 56\nFOO")
|
|
333
|
+
# # => { :body => "FOO", :status => "DELETED", :id => 56 }
|
|
334
|
+
#
|
|
335
|
+
def parse_response(cmd, res)
|
|
336
|
+
status = res.chomp
|
|
337
|
+
body_values = status.split(/\s/)
|
|
338
|
+
status = body_values[0]
|
|
339
|
+
if status == "DRAINING" && cmd.strip.start_with?("drain")
|
|
340
|
+
return { status: status }
|
|
341
|
+
end
|
|
342
|
+
raise UnexpectedResponse.from_status(status, cmd) if UnexpectedResponse::ERROR_STATES.include?(status)
|
|
343
|
+
body = nil
|
|
344
|
+
if status == 'FLUSHED'
|
|
345
|
+
return { status: status, id: body_values[1] }
|
|
346
|
+
end
|
|
347
|
+
if ['OK','FOUND', 'RESERVED'].include?(status)
|
|
348
|
+
bytes_size = body_values[-1].to_i
|
|
349
|
+
raw_body = connection.read(bytes_size)
|
|
350
|
+
body = if status == 'OK'
|
|
351
|
+
psych_v4_valid_body = raw_body.gsub(/^(.*?): (.*)$/) { "#{$1}: #{$2.gsub(/[\:\-\~]/, '_')}" }
|
|
352
|
+
YAML.load(psych_v4_valid_body)
|
|
353
|
+
else
|
|
354
|
+
config.job_parser.call(raw_body)
|
|
355
|
+
end
|
|
356
|
+
crlf = connection.read(2) # \r\n
|
|
357
|
+
raise ExpectedCrlfError.new('EXPECTED_CRLF', cmd) if crlf != "\r\n"
|
|
358
|
+
end
|
|
359
|
+
id = body_values[1]
|
|
360
|
+
response = { :status => status }
|
|
361
|
+
response[:id] = id if id
|
|
362
|
+
response[:body] = body if body
|
|
363
|
+
response[:state] = body_values[2] if status == 'INSERTED' && body_values[2]
|
|
364
|
+
response
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Returns configuration options for tuber
|
|
368
|
+
#
|
|
369
|
+
# @return [Tuber::Configuration] configuration object
|
|
370
|
+
def config
|
|
371
|
+
Tuber.configuration
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
private
|
|
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.
|
|
381
|
+
def _initialize_tubes
|
|
382
|
+
if @tubes_watched != ['default'] || @tube_weights.any?
|
|
383
|
+
tubes_watched.each do |t|
|
|
384
|
+
weight = @tube_weights[t]
|
|
385
|
+
transmit(weight ? "watch #{t} #{weight}" : "watch #{t}", init: false)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
transmit("ignore default", init: false) unless @tubes_watched.include?('default')
|
|
389
|
+
end
|
|
390
|
+
|
|
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'
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Wrapper method for capturing certain failures and retry the payload block
|
|
397
|
+
#
|
|
398
|
+
# @param [Proc] block The command to execute.
|
|
399
|
+
# @param [Integer] retry_interval The time to wait before the next retry
|
|
400
|
+
# @param [Boolean] retransmit Whether the command is safe to re-send after
|
|
401
|
+
# a reconnect. When false the connection is still healed (reconnect +
|
|
402
|
+
# tube re-init) but the original connection error is re-raised.
|
|
403
|
+
# @param [Integer] tries The maximum number of tries in draining mode
|
|
404
|
+
# @return [Object] Result of the block passed
|
|
405
|
+
#
|
|
406
|
+
def _with_retry(retry_interval: DEFAULT_RETRY_INTERVAL, init: true, tries: MAX_RETRIES, retransmit: true, &block)
|
|
407
|
+
yield
|
|
408
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE,
|
|
409
|
+
Errno::ECONNREFUSED => ex
|
|
410
|
+
_reconnect(tries: _connect_tries, retry_interval: retry_interval)
|
|
411
|
+
_initialize_tubes if init
|
|
412
|
+
raise ex unless retransmit
|
|
413
|
+
retry
|
|
414
|
+
rescue Tuber::DrainingError
|
|
415
|
+
tries -= 1
|
|
416
|
+
if tries.zero?
|
|
417
|
+
close
|
|
418
|
+
raise
|
|
419
|
+
end
|
|
420
|
+
sleep(retry_interval)
|
|
421
|
+
retry
|
|
422
|
+
end
|
|
423
|
+
|
|
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.
|
|
436
|
+
#
|
|
437
|
+
# @param [Integer] tries The maximum number of attempts to reconnect
|
|
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)
|
|
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)
|
|
451
|
+
establish_connection
|
|
452
|
+
rescue *CONNECT_ERRORS => ex
|
|
453
|
+
tries -= 1
|
|
454
|
+
_raise_not_connected!(ex) if tries <= 0
|
|
455
|
+
sleep(retry_interval || DEFAULT_RETRY_INTERVAL)
|
|
456
|
+
retry
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# The host provided by the TUBER_URL environment variable (or BEANSTALKD_URL,
|
|
460
|
+
# honoured for compatibility with beanstalkd tooling), if available.
|
|
461
|
+
#
|
|
462
|
+
# @return [String] A server host address
|
|
463
|
+
# @example
|
|
464
|
+
# ENV['TUBER_URL'] = "localhost:1212"
|
|
465
|
+
# # => 'localhost:1212'
|
|
466
|
+
#
|
|
467
|
+
def _host_from_env
|
|
468
|
+
url = ENV['TUBER_URL'] || ENV['BEANSTALKD_URL']
|
|
469
|
+
url.respond_to?(:length) && url.length > 0 && url.strip
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Packs a timeout value (in seconds) into a struct timeval binary string.
|
|
473
|
+
# Supports fractional seconds (e.g., 0.5 => 500000 usec).
|
|
474
|
+
def _timeval_for(timeout)
|
|
475
|
+
sec = timeout.to_i
|
|
476
|
+
usec = ((timeout.to_f - sec) * 1_000_000).to_i
|
|
477
|
+
[sec, usec].pack('l_l_')
|
|
478
|
+
end
|
|
479
|
+
|
|
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
|
|
492
|
+
# @raise [Tuber::NotConnected] Beanstalkd is no longer connected
|
|
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
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
end # Connection
|
|
500
|
+
end # Tuber
|
data/lib/tuber/errors.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Tuber
|
|
4
|
+
# Raises when a beanstalkd instance is no longer accessible.
|
|
5
|
+
class NotConnected < RuntimeError; end
|
|
6
|
+
# Raises when the tube name specified is invalid.
|
|
7
|
+
class InvalidTubeName < RuntimeError; end
|
|
8
|
+
# Raises when a job has not been reserved properly.
|
|
9
|
+
class JobNotReserved < RuntimeError; end
|
|
10
|
+
|
|
11
|
+
# Abstract class for errors that occur when a command does not complete successfully.
|
|
12
|
+
class UnexpectedResponse < RuntimeError
|
|
13
|
+
# Set of status states that are considered errors
|
|
14
|
+
ERROR_STATES = %w(OUT_OF_MEMORY INTERNAL_ERROR
|
|
15
|
+
BAD_FORMAT UNKNOWN_COMMAND JOB_TOO_BIG DRAINING
|
|
16
|
+
TIMED_OUT DEADLINE_SOON NOT_FOUND NOT_IGNORED EXPECTED_CRLF)
|
|
17
|
+
|
|
18
|
+
# @!attribute status
|
|
19
|
+
# @return [String] returns beanstalkd response status
|
|
20
|
+
# @example @ex.status # => "NOT_FOUND"
|
|
21
|
+
# @!attribute cmd
|
|
22
|
+
# @return [String] returns beanstalkd request command
|
|
23
|
+
# @example @ex.cmd # => "stats-job 23"
|
|
24
|
+
attr_reader :status, :cmd
|
|
25
|
+
|
|
26
|
+
# Initialize unexpected response error
|
|
27
|
+
#
|
|
28
|
+
# @param [Tuber::UnexpectedResponse] status Unexpected response object
|
|
29
|
+
# @param [String] cmd Beanstalkd request command
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# Tuber::UnexpectedResponse.new(NotFoundError, 'bury 123')
|
|
33
|
+
#
|
|
34
|
+
def initialize(status, cmd)
|
|
35
|
+
@status, @cmd = status, cmd
|
|
36
|
+
super("Response failed with: #{status}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Translate beanstalkd error status to ruby Exeception
|
|
40
|
+
#
|
|
41
|
+
# @param [String] status Beanstalkd error status
|
|
42
|
+
# @param [String] cmd Beanstalkd request command
|
|
43
|
+
#
|
|
44
|
+
# @return [Tuber::UnexpectedResponse] Exception for the status provided
|
|
45
|
+
# @example
|
|
46
|
+
# Tuber::UnexpectedResponse.new('NOT_FOUND', 'bury 123')
|
|
47
|
+
#
|
|
48
|
+
def self.from_status(status, cmd)
|
|
49
|
+
error_klazz_name = status.split('_').map { |w| w.capitalize }.join
|
|
50
|
+
error_klazz_name << "Error" unless error_klazz_name =~ /Error$/
|
|
51
|
+
error_klazz = Tuber.const_get(error_klazz_name)
|
|
52
|
+
error_klazz.new(status, cmd)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Raises when the beanstalkd instance runs out of memory
|
|
57
|
+
class OutOfMemoryError < UnexpectedResponse; end
|
|
58
|
+
# Raises when the beanstalkd instance is draining and new jobs cannot be inserted
|
|
59
|
+
class DrainingError < UnexpectedResponse; end
|
|
60
|
+
# Raises when the job or tube cannot be found
|
|
61
|
+
class NotFoundError < UnexpectedResponse; end
|
|
62
|
+
# Raises when the job reserved is going to be released within a second.
|
|
63
|
+
class DeadlineSoonError < UnexpectedResponse; end
|
|
64
|
+
# Raises when a beanstalkd has an internal error.
|
|
65
|
+
class InternalError < UnexpectedResponse; end
|
|
66
|
+
# Raises when a command was not properly formatted.
|
|
67
|
+
class BadFormatError < UnexpectedResponse; end
|
|
68
|
+
# Raises when a command was sent that is unknown.
|
|
69
|
+
class UnknownCommandError < UnexpectedResponse; end
|
|
70
|
+
# Raises when command does not have proper CRLF suffix.
|
|
71
|
+
class ExpectedCrlfError < UnexpectedResponse; end
|
|
72
|
+
# Raises when the body of a job was too large.
|
|
73
|
+
class JobTooBigError < UnexpectedResponse; end
|
|
74
|
+
# Raises when a job was attempted to be reserved but the timeout occurred.
|
|
75
|
+
class TimedOutError < UnexpectedResponse; end
|
|
76
|
+
# Raises when a tube could not be ignored because it is the last watched tube.
|
|
77
|
+
class NotIgnoredError < UnexpectedResponse; end
|
|
78
|
+
end
|