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 +4 -4
- data/.github/workflows/ruby.yml +4 -2
- data/CHANGELOG.md +16 -0
- data/README.md +28 -0
- data/beaneater.gemspec +1 -0
- data/lib/beaneater/configuration.rb +8 -0
- data/lib/beaneater/connection.rb +313 -57
- data/lib/beaneater/errors.rb +14 -0
- data/lib/beaneater/job/collection.rb +42 -6
- data/lib/beaneater/stats/stat_struct.rb +6 -1
- data/lib/beaneater/version.rb +1 -1
- data/test/connection_test.rb +610 -15
- data/test/job_test.rb +11 -0
- data/test/jobs_test.rb +429 -0
- data/test/stat_struct_test.rb +5 -0
- data/test/test_helper.rb +45 -0
- data/test/tube_test.rb +123 -0
- data/test/tubes_test.rb +16 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7397bf5f83a1ba3ff93d90fb1ee7718d8dcaf90ac45ecdedd236fe4fe666c5eb
|
|
4
|
+
data.tar.gz: e3f0776719d39a2eb235a921b505bddb3af25be0c915718be3b24a46dd0afd0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e6b026dbb985f693b2fb0c494084223f86100850210cef83af8d6c7a951e6f81b2dd620262d5f28fa4a18ec8c26542409c45fd9404199dba766470af8265af1
|
|
7
|
+
data.tar.gz: a73f631654a1147afcaed139b24b8ec1748fde51b02ff9e84492e8ccdcae81cc84177951b5faebf5d8c06189188b7448e21c346f2b2650219467bcbe1f62b1c8
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -22,10 +22,10 @@ jobs:
|
|
|
22
22
|
runs-on: ubuntu-latest
|
|
23
23
|
strategy:
|
|
24
24
|
matrix:
|
|
25
|
-
ruby-version: ['2.
|
|
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@
|
|
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
|
data/lib/beaneater/connection.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
157
|
-
YAML.load(psych_v4_valid_body)
|
|
217
|
+
parse_yaml_body(cmd, raw_body)
|
|
158
218
|
else
|
|
159
|
-
|
|
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
|
-
|
|
326
|
+
_exchange("watch #{t}")
|
|
184
327
|
end
|
|
185
328
|
|
|
186
|
-
|
|
329
|
+
_exchange("ignore default") unless tubes_watched.include?('default')
|
|
187
330
|
end
|
|
188
331
|
|
|
189
|
-
|
|
332
|
+
_exchange("use #{tube_used}") if @tube_used != 'default'
|
|
190
333
|
end
|
|
191
334
|
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
if
|
|
209
|
-
|
|
380
|
+
state[:draining_tries] -= 1
|
|
381
|
+
if state[:draining_tries].zero?
|
|
382
|
+
_drop_connection
|
|
210
383
|
raise
|
|
211
384
|
end
|
|
212
|
-
|
|
213
|
-
retry
|
|
385
|
+
[false, state[:retry_interval]]
|
|
214
386
|
end
|
|
215
387
|
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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!
|
data/lib/beaneater/errors.rb
CHANGED
|
@@ -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.
|