tuber 0.5.1 → 0.7.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/CHANGELOG.md +37 -0
- data/Gemfile +3 -1
- data/README.md +145 -0
- data/lib/tuber/configuration.rb +18 -0
- data/lib/tuber/connection.rb +243 -23
- data/lib/tuber/errors.rb +57 -3
- data/lib/tuber/schedules.rb +288 -0
- data/lib/tuber/tube/collection.rb +6 -2
- data/lib/tuber/tube/record.rb +21 -10
- data/lib/tuber/version.rb +1 -1
- data/lib/tuber.rb +31 -1
- data/test/connection_test.rb +356 -0
- data/test/errors_test.rb +7 -0
- data/test/schedules_test.rb +382 -0
- data/test/test_helper.rb +18 -0
- data/test/tubes_test.rb +15 -2
- metadata +4 -3
- data/TODO +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dad07a18da118a0c3f50cdb00de169fbc73c4691e7484c422d98bccd12b750e
|
|
4
|
+
data.tar.gz: eeb2247e67cf72d407c578f9a0e2e61820ebc3b3594ce6da39f90eb312ef23d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 476561b12a94b1017ec600d249236ad1296a3760c19c8fae126bace4ae26de1b9d448c54b74413dbf7805eb3ed91d8bf4ee53c17a148fe194705c43783e44e71
|
|
7
|
+
data.tar.gz: d3e34a35f30ee2ae578bdfb1f88c243585a2bbfcb9de4e0d76241d1839e35cb51715fcd5a9921ff5e002557c1d2f53f66aad1daea18c6114ae62687d43b74e58
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ The tuber gem is a fork of [beaneater](https://github.com/beanstalkd/beaneater),
|
|
|
4
4
|
beaneater 1.1.4. Entries from 1.1.4 down are beaneater's history, kept for reference.
|
|
5
5
|
(0.0.1 was an empty placeholder published to claim the gem name.)
|
|
6
6
|
|
|
7
|
+
## 0.7.0 (October 11 2026)
|
|
8
|
+
|
|
9
|
+
Recurring schedules. A Tuber with the scheduler runs an app's recurring jobs
|
|
10
|
+
itself, so the app can drop its scheduler process. This release adds the client
|
|
11
|
+
side. Nothing changes for code that doesn't call it.
|
|
12
|
+
|
|
13
|
+
* Add `Tuber#schedules`: `sync(owner) { |s| s.add ... }` replaces an owner's whole set of entries in one step, and `list` and `stats` show what the server holds. `s.add` takes `cron:` (with `zone:`), `every:` (with `offset:`) or a raw `spec:`, the put options with their usual defaults and value shapes, and `catchup:`. See "Recurring Schedules" in the README.
|
|
14
|
+
* `sync` refuses an empty set unless given `allow_empty: true`. An empty sync removes every entry the owner has, and an app that loaded an empty config by mistake shouldn't wipe its schedule.
|
|
15
|
+
* `sync` runs on a short-lived connection of its own, closed afterwards. A worker blocked in `reserve` on the client's connection can't hold it up, and an error partway can't leave that connection mid-sync. If the socket drops, the whole sync is re-run from the start, never resumed partway.
|
|
16
|
+
* Add `Tuber::ScheduleSyncError` for a sync tuber refuses. It names the owner, the refused entry (`#entry`) and tuber's reply (`#status`). The gem aborts the sync before raising, so the previous schedule keeps running. `#applied?` is true for the one exception: a commit that took effect but couldn't be written to disk.
|
|
17
|
+
* `schedules.stats` returns a `StatStruct`, like tube and job stats: `stats.next_fire` or `stats["next-fire"]`. Times come back as `Time` in the entry's own zone, counters as Integers, and empty fields as nil.
|
|
18
|
+
* Add `Connection#transmit_raw`, which returns an `OK <bytes>` body unparsed. `transmit`'s YAML handling rewrites `:`, `-` and `~` inside values, and schedule stats are full of them.
|
|
19
|
+
* Fix a put refused with `OUT_OF_STORAGE` coming back as a result hash, as though it had succeeded. Tuber sends that reply once a persistent server's `--max-storage-bytes` budget is spent. It now raises the new `Tuber::OutOfStorageError`, as `OUT_OF_MEMORY` raises `OutOfMemoryError`.
|
|
20
|
+
* `Tuber::UnexpectedResponse.new` takes an optional message as a third argument.
|
|
21
|
+
|
|
22
|
+
## 0.6.0 (September 16 2026)
|
|
23
|
+
|
|
24
|
+
Reconnection release. The gem retried reconnects but not connects, so a
|
|
25
|
+
consumer that caught `Tuber::NotConnected` and built a new client — the obvious
|
|
26
|
+
thing to write — never reached the retrying path, and a server restart of a few
|
|
27
|
+
seconds was enough to kill it. `reconnect!` makes the existing healing
|
|
28
|
+
reachable, and `connect_retries` gives a cold start a window wider than the
|
|
29
|
+
outage. Existing behaviour is unchanged until `connect_retries` is raised, with
|
|
30
|
+
two exceptions noted below: `NotConnected` messages gained a suffix (the
|
|
31
|
+
historical text remains a strict prefix), and a connect error other than
|
|
32
|
+
`ECONNREFUSED` is now retried and surfaced as `NotConnected` rather than
|
|
33
|
+
escaping as a raw `Errno`.
|
|
34
|
+
|
|
35
|
+
* Add `Tuber#reconnect!` / `Connection#reconnect!`: re-establishes the connection and replays its tube state (watched tubes with their weights, the used tube, the reserve mode). This healing only ever ran when the connection itself noticed a dropped socket, so a consumer that caught `Tuber::NotConnected` had no way to reach it and had to build a new client — which starts with no tube state and has to re-watch and re-weight every tube by hand.
|
|
36
|
+
* Add `connect_retries` and `connect_retry_interval` configuration. The initial connect took exactly one attempt with no retry, so a cold start during a server restart — a web boot, a fresh `Tuber.new`, a daemon rebuilding its client after an error — failed where an established connection would have healed. Defaults to the historical single attempt; raising it also lifts the reconnect budget of connections already established, so one knob covers both.
|
|
37
|
+
* Retry a connect that fails for any reason the server might be unreachable (`ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, DNS failure, connect timeout), not only `ECONNREFUSED`. Those previously escaped as a raw `Errno` from the middle of a reconnect, a class no consumer thinks to rescue.
|
|
38
|
+
* `Tuber::NotConnected` now names the underlying error in its message and keeps it as the exception's `cause`. A bare `rescue` collapsed every connect failure into "Connection to beanstalk '...' is closed!", hiding whether the server refused, timed out, or was never resolved — exactly what you need during an outage.
|
|
39
|
+
* `Connection#close` no longer raises if the socket cannot be closed cleanly; the connection is gone either way and `reconnect!` must not fail on the way to replacing it.
|
|
40
|
+
* Fix a reconnect silently dropping per-tube weights and the connection's reserve mode ([#1](https://github.com/tuberq/tuber-gem/issues/1)). Both are per-connection server state that a fresh socket resets, so a consumer that healed across a brief server restart kept working but quietly fell back to FIFO. `Connection` now remembers the weight each tube was watched with and the mode set by `Tubes#reserve_mode`, and replays them (watch, then `ignore default`, then `reserve-mode`) when re-initialising tubes.
|
|
41
|
+
* Fix `_initialize_tubes` sending `ignore default` after a reconnect even when `default` was one of the watched tubes, which dropped it from the watch list.
|
|
42
|
+
* `Connection#add_to_watched` now takes an optional weight as a second argument, and `Connection#tube_weights` / `Connection#reserve_mode` expose the replayed state.
|
|
43
|
+
|
|
7
44
|
## 0.5.1 (August 13 2026)
|
|
8
45
|
|
|
9
46
|
* Fix empty keyword arguments forwarded through `Tube#transmit`, `Tubes#transmit` and `Jobs#transmit` arriving as an extra positional `{}` on Ruby 2.7
|
data/Gemfile
CHANGED
|
@@ -6,7 +6,9 @@ source 'https://rubygems.org'
|
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
|
-
|
|
9
|
+
# 3.5.1 fixes CVE-2020-26298 (XSS when processing quotes). The old '~> 1'
|
|
10
|
+
# pin was inherited from beaneater and capped us below the fix.
|
|
11
|
+
gem 'redcarpet', '~> 3.6', '>= 3.5.1'
|
|
10
12
|
gem 'github-markup'
|
|
11
13
|
gem 'yard'
|
|
12
14
|
end
|
data/README.md
CHANGED
|
@@ -72,6 +72,8 @@ Tuber.configure do |config|
|
|
|
72
72
|
# config.resolv_timeout = nil
|
|
73
73
|
# config.read_timeout = nil
|
|
74
74
|
# config.write_timeout = nil
|
|
75
|
+
# config.connect_retries = 0
|
|
76
|
+
# config.connect_retry_interval = 1
|
|
75
77
|
end
|
|
76
78
|
```
|
|
77
79
|
|
|
@@ -90,6 +92,47 @@ The above options are all defaults, so only include a configuration block if you
|
|
|
90
92
|
@tuber.close
|
|
91
93
|
```
|
|
92
94
|
|
|
95
|
+
#### Surviving a server restart
|
|
96
|
+
|
|
97
|
+
An established connection heals itself: when a command notices the socket has
|
|
98
|
+
died, the connection reconnects, replays its tube state, and re-sends the
|
|
99
|
+
command (except for the non-idempotent verbs — `put`, `delete`, `release`,
|
|
100
|
+
`bury`, `touch`, `kick` — where a blind re-send could duplicate a job or act on
|
|
101
|
+
a reservation that died with the old socket, so the original error is raised
|
|
102
|
+
for the caller to decide).
|
|
103
|
+
|
|
104
|
+
That healing gets three connect attempts a second apart. A server restart
|
|
105
|
+
typically takes longer, so give the client a budget that outlasts it:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Tuber.configure do |config|
|
|
109
|
+
config.connect_retries = 15 # extra attempts, 0 = a single attempt
|
|
110
|
+
config.connect_retry_interval = 1 # seconds between them
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`connect_retries` covers both a cold start (`Tuber.new` while the server is
|
|
115
|
+
down) and reconnects on a client you already hold. It defaults to `0` — a
|
|
116
|
+
single attempt — which is the historical behaviour.
|
|
117
|
+
|
|
118
|
+
When a command does surface `Tuber::NotConnected`, heal the client you have
|
|
119
|
+
rather than building a new one:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
begin
|
|
123
|
+
job = @tuber.tubes.reserve
|
|
124
|
+
rescue Tuber::NotConnected
|
|
125
|
+
@tuber.reconnect! # watched tubes, their weights, the used tube and the
|
|
126
|
+
retry # reserve mode all come back with it
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A fresh `Tuber.new` starts with no tube state, so a worker that reconnects that
|
|
131
|
+
way has to re-watch and re-weight every tube by hand — and will silently fall
|
|
132
|
+
back to FIFO if it forgets the reserve mode. `reconnect!` replays all of it.
|
|
133
|
+
It takes the same budget as the config, overridable per call:
|
|
134
|
+
`@tuber.reconnect!(tries: 30, retry_interval: 2)`.
|
|
135
|
+
|
|
93
136
|
### Tubes
|
|
94
137
|
|
|
95
138
|
Tubes are named work queues. Jobs are `put` into the used tube and `reserve`d from watched tubes. Each tube has a _ready_, _delayed_, and _buried_ queue.
|
|
@@ -270,6 +313,10 @@ job = @tuber.tubes.reserve # batch-jobs selected 6x as often as email
|
|
|
270
313
|
|
|
271
314
|
Tubes default to weight 1. Switch back with `reserve_mode(:fifo)`.
|
|
272
315
|
|
|
316
|
+
Weights and reserve mode are per-connection server state. The client remembers
|
|
317
|
+
both and replays them if the connection has to be re-established, so a worker
|
|
318
|
+
that heals across a server blip keeps reserving by weight.
|
|
319
|
+
|
|
273
320
|
### Batch Reserve (Tuber only)
|
|
274
321
|
|
|
275
322
|
Reserve multiple jobs atomically in a single call:
|
|
@@ -324,6 +371,98 @@ lower than expected means jobs hit their TTR and went back to the queue while th
|
|
|
324
371
|
worker was busy — otherwise invisible, since nothing notifies a worker that it
|
|
325
372
|
lost a job.
|
|
326
373
|
|
|
374
|
+
### Recurring Schedules (Tuber only)
|
|
375
|
+
|
|
376
|
+
Tuber can run an app's recurring jobs itself, so the app needs no scheduler
|
|
377
|
+
process (whenever, rufus-scheduler, sidekiq-cron, a `bin/scheduler` container).
|
|
378
|
+
The app sends its complete schedule under a name of its choosing, the *owner*,
|
|
379
|
+
and tuber puts each entry's body into its tube at the times the entry names. A
|
|
380
|
+
fire is an ordinary put: workers reserve, delete and bury it like any other job.
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
tuber = Tuber.new
|
|
384
|
+
|
|
385
|
+
result = tuber.schedules.sync("splat") do |s|
|
|
386
|
+
s.add "monitor_evaluation", tube: "splat.checkins", every: 60,
|
|
387
|
+
body: { class: "Monitors::EvaluateJob", args: [] }.to_json,
|
|
388
|
+
idp: "monitor_evaluation", pri: 1024
|
|
389
|
+
s.add "histogram_rollup", tube: "splat.maintenance", cron: "5 * * * *",
|
|
390
|
+
body: { class: "Analytics::HistogramRollupJob", args: [] }.to_json,
|
|
391
|
+
idp: "histogram_rollup", con: "maintenance", pri: 1024
|
|
392
|
+
s.add "data_retention", tube: "splat.maintenance",
|
|
393
|
+
cron: "0 2 * * *", zone: "Australia/Melbourne",
|
|
394
|
+
body: { class: "Maintenance::RetentionJob", args: [] }.to_json,
|
|
395
|
+
idp: "data_retention", con: "maintenance", pri: 1024
|
|
396
|
+
end
|
|
397
|
+
result.to_h # => {added: 3, changed: 0, removed: 0, unchanged: 0}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **Send the complete set every time.** An entry you leave out is removed. An
|
|
401
|
+
edited entry keeps its name and is updated in place. An identical sync changes
|
|
402
|
+
nothing, not even when entries next fire, so every replica may run it.
|
|
403
|
+
- **An empty set is refused** with `ArgumentError`, since it would remove every
|
|
404
|
+
entry the owner has. Pass `allow_empty: true` when that is what you mean.
|
|
405
|
+
- **Sync when a release starts, not on reconnect.** Use a release step (a Kamal
|
|
406
|
+
`pre-deploy` hook, a Heroku `release` phase) or sync once at process boot.
|
|
407
|
+
Tuber keeps schedules across its own restarts (with `-b`), and during a
|
|
408
|
+
rolling deploy an old process re-syncing after a reconnect would put back
|
|
409
|
+
entries the new release removed. The gem never syncs on its own.
|
|
410
|
+
- A sync uses a short-lived connection of its own, so a worker blocked in
|
|
411
|
+
`reserve` on the client's connection doesn't hold it up.
|
|
412
|
+
|
|
413
|
+
`s.add` takes the entry's name and:
|
|
414
|
+
|
|
415
|
+
| Option | |
|
|
416
|
+
| --- | --- |
|
|
417
|
+
| `tube:`, `body:` | Required. The body goes through `job_serializer`, as for `put` |
|
|
418
|
+
| `cron:` | 5-field cron or a macro (`"@daily"`); `zone:` adds an IANA zone (default UTC) |
|
|
419
|
+
| `every:` | Seconds, or `"10m"`, `"1h"`, `"1d"`, aligned to the Unix epoch; `offset:` shifts it |
|
|
420
|
+
| `spec:` | The spec as tuber reads it, instead of `cron:` or `every:` |
|
|
421
|
+
| `pri:`, `delay:`, `ttr:` | As for `put`, with the same defaults |
|
|
422
|
+
| `idp:`, `con:`, `grp:`, `aft:` | As for `put`: `idp: "key"` or `["key", ttl]`, `con: "key"` or `["key", limit]` |
|
|
423
|
+
| `catchup:` | For ticks missed while tuber was down: `:once` (default) fires once for the gap, an Integer fires it only if at most that many seconds late, `:never` skips it |
|
|
424
|
+
|
|
425
|
+
A `con:` value is a key, not a number: every job with `con: "maintenance"`
|
|
426
|
+
shares one slot, so entries that share it run one after another.
|
|
427
|
+
|
|
428
|
+
Anything tuber would refuse that the gem can see — a bad name, two specs, a body
|
|
429
|
+
that isn't a String — raises `ArgumentError` before anything is sent. When tuber
|
|
430
|
+
refuses the sync, it raises `Tuber::ScheduleSyncError`, and nothing changed: the
|
|
431
|
+
previous schedule keeps running. `#entry` names the refused entry (nil when tuber
|
|
432
|
+
refused the sync as a whole) and `#status` gives tuber's reply, such as
|
|
433
|
+
`BAD_SCHEDULE` for a zone typo like `Australia/Melborne`. The one exception is
|
|
434
|
+
`#applied?`: the commit took effect but tuber couldn't write it to disk
|
|
435
|
+
(`INTERNAL_ERROR`). It runs until tuber restarts, so sync again once the disk is
|
|
436
|
+
fixed.
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
begin
|
|
440
|
+
tuber.schedules.sync("splat") { |s| ... }
|
|
441
|
+
rescue Tuber::ScheduleSyncError => e
|
|
442
|
+
Rails.logger.error(e.message) # names the entry and the reason
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
To see what tuber holds:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
tuber.schedules.list # => [["splat", "data_retention"], ...] across owners
|
|
450
|
+
tuber.schedules.list("splat")
|
|
451
|
+
stats = tuber.schedules.stats("splat", "data_retention") # nil if there's no such entry
|
|
452
|
+
stats.next_fire # => 2026-10-12 02:00:00 +1100 (next_fire_2, next_fire_3 too)
|
|
453
|
+
stats.last_result # => "INSERTED 4711"
|
|
454
|
+
stats.buried_streak # => 0
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**`idp:` and buried jobs.** With `idp:`, a fire is skipped while the previous
|
|
458
|
+
fire's job is still in the tube, which is what you want when a run outlasts its
|
|
459
|
+
interval. But a *buried* job keeps its key too, so every later fire dedups
|
|
460
|
+
against it and queues nothing until it is kicked or deleted. `buried_streak`
|
|
461
|
+
counts those fires: alert when it is above 0.
|
|
462
|
+
|
|
463
|
+
Tuber's [scheduling guide](https://github.com/tuberq/tuber/blob/main/docs/scheduling.md)
|
|
464
|
+
covers spec syntax, daylight saving, catch-up and monitoring.
|
|
465
|
+
|
|
327
466
|
### Stats
|
|
328
467
|
|
|
329
468
|
```ruby
|
|
@@ -342,6 +481,10 @@ Tuber.configure do |config|
|
|
|
342
481
|
config.job_parser = lambda { |body| body }
|
|
343
482
|
config.job_serializer = lambda { |body| body }
|
|
344
483
|
config.tuber_url = 'localhost:11300'
|
|
484
|
+
|
|
485
|
+
# Connect attempts; see "Surviving a server restart" above.
|
|
486
|
+
config.connect_retries = 0
|
|
487
|
+
config.connect_retry_interval = 1
|
|
345
488
|
end
|
|
346
489
|
```
|
|
347
490
|
|
|
@@ -362,6 +505,8 @@ end
|
|
|
362
505
|
| Tuber::NotFoundError | Job or tube not found. |
|
|
363
506
|
| Tuber::TimedOutError | Reserve timed out. |
|
|
364
507
|
| Tuber::JobNotReserved | Action requires a reserved job. |
|
|
508
|
+
| Tuber::OutOfStorageError | A persistent Tuber's disk budget (`--max-storage-bytes`) is spent. |
|
|
509
|
+
| Tuber::ScheduleSyncError | Tuber refused a schedule sync; see Recurring Schedules. |
|
|
365
510
|
|
|
366
511
|
See the [Tuber protocol](https://github.com/tuberq/tuber/blob/main/docs/protocol.md) (a superset of the [beanstalk protocol](https://github.com/beanstalkd/beanstalkd/blob/master/doc/protocol.txt)) for additional error types.
|
|
367
512
|
|
data/lib/tuber/configuration.rb
CHANGED
|
@@ -15,6 +15,21 @@ class Tuber
|
|
|
15
15
|
attr_accessor :read_timeout # socket read timeout in seconds
|
|
16
16
|
attr_accessor :write_timeout # socket write timeout in seconds
|
|
17
17
|
|
|
18
|
+
# How hard a client tries to connect — "how long should this ride out a
|
|
19
|
+
# server restart", in one knob.
|
|
20
|
+
#
|
|
21
|
+
# An established connection has always retried when it notices a dropped
|
|
22
|
+
# socket (Tuber::Connection::MAX_RETRIES attempts), but the *initial*
|
|
23
|
+
# connect got exactly one: a cold start during an outage — a web boot, a
|
|
24
|
+
# fresh Tuber.new, a daemon rebuilding its client after an error — failed
|
|
25
|
+
# where a held connection would have healed.
|
|
26
|
+
#
|
|
27
|
+
# Defaults to that single attempt, so nothing changes until you raise it.
|
|
28
|
+
# Raising it covers the initial connect and lifts the reconnect budget of
|
|
29
|
+
# connections already established along with it.
|
|
30
|
+
attr_accessor :connect_retries # extra connect attempts, 0 = one shot
|
|
31
|
+
attr_accessor :connect_retry_interval # seconds between connect attempts
|
|
32
|
+
|
|
18
33
|
def initialize
|
|
19
34
|
@default_put_delay = 0
|
|
20
35
|
@default_put_pri = 65536
|
|
@@ -26,6 +41,9 @@ class Tuber
|
|
|
26
41
|
@resolv_timeout = nil
|
|
27
42
|
@read_timeout = nil
|
|
28
43
|
@write_timeout = nil
|
|
44
|
+
@connect_retries = 0
|
|
45
|
+
# Matches Tuber::Connection::DEFAULT_RETRY_INTERVAL, which is not loaded yet.
|
|
46
|
+
@connect_retry_interval = 1
|
|
29
47
|
end
|
|
30
48
|
end # Configuration
|
|
31
49
|
end # Tuber
|
data/lib/tuber/connection.rb
CHANGED
|
@@ -33,7 +33,17 @@ class Tuber
|
|
|
33
33
|
# @returns [Array<String>] returns currently watched tube names
|
|
34
34
|
# @!attribute tube_used
|
|
35
35
|
# @returns [String] returns currently used tube name
|
|
36
|
-
|
|
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
|
|
37
47
|
|
|
38
48
|
# Default port value for beanstalk connection
|
|
39
49
|
DEFAULT_PORT = 11300
|
|
@@ -49,6 +59,25 @@ class Tuber
|
|
|
49
59
|
# keeps the transparent retry.
|
|
50
60
|
NON_IDEMPOTENT_COMMANDS = %w[put delete delete-batch release bury touch touch-all kick kick-job].freeze
|
|
51
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
|
+
|
|
52
81
|
# Initializes new connection.
|
|
53
82
|
#
|
|
54
83
|
# @param [String] address beanstalkd instance address.
|
|
@@ -61,15 +90,23 @@ class Tuber
|
|
|
61
90
|
# @b.connection.host # => '127.0.0.1'
|
|
62
91
|
# @b.connection.port # => '11300'
|
|
63
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
|
+
#
|
|
64
96
|
def initialize(address)
|
|
65
97
|
@address = address || _host_from_env || Tuber.configuration.tuber_url
|
|
66
98
|
@mutex = Mutex.new
|
|
67
99
|
@tube_used = 'default'
|
|
68
100
|
@tubes_watched = ['default']
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
110
|
end
|
|
74
111
|
|
|
75
112
|
# Send commands to beanstalkd server via connection.
|
|
@@ -163,6 +200,81 @@ class Tuber
|
|
|
163
200
|
end
|
|
164
201
|
end
|
|
165
202
|
|
|
203
|
+
# Sends a command answered with `OK <bytes>` and returns the body
|
|
204
|
+
# unparsed. For replies whose values #parse_response would mangle:
|
|
205
|
+
# its Psych workaround rewrites `:`, `-` and `~` inside every value, and
|
|
206
|
+
# stats-schedule's times, specs and tags are full of them.
|
|
207
|
+
#
|
|
208
|
+
# @param [String] command A command whose success reply is `OK <bytes>`
|
|
209
|
+
# @return [String] The reply body (the YAML document), without its CRLF
|
|
210
|
+
# @raise [Tuber::UnexpectedResponse] Any other reply, e.g. NotFoundError
|
|
211
|
+
#
|
|
212
|
+
def transmit_raw(command)
|
|
213
|
+
_with_retry do
|
|
214
|
+
@mutex.synchronize do
|
|
215
|
+
_raise_not_connected! unless connection
|
|
216
|
+
|
|
217
|
+
connection.write(command + "\r\n")
|
|
218
|
+
status, bytes = connection.readline.chomp.split(/\s/, 2)
|
|
219
|
+
raise UnexpectedResponse.from_status(status, command) unless status == 'OK'
|
|
220
|
+
|
|
221
|
+
body = connection.read(bytes.to_i)
|
|
222
|
+
crlf = connection.read(2)
|
|
223
|
+
raise ExpectedCrlfError.new('EXPECTED_CRLF', command) unless crlf == "\r\n"
|
|
224
|
+
body.force_encoding(Encoding::UTF_8)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Replaces +owner+'s recurring schedule with +entries+ in one exchange:
|
|
230
|
+
# schedule-begin, a schedule-put per entry, schedule-commit. The mutex is
|
|
231
|
+
# held throughout, so no other command interleaves with it on the socket.
|
|
232
|
+
#
|
|
233
|
+
# A dropped socket makes the server discard what was staged, so the whole
|
|
234
|
+
# exchange is re-run on a fresh connection — never resumed partway. That
|
|
235
|
+
# is safe because a sync is idempotent; if the drop came after the commit
|
|
236
|
+
# landed, the re-run reports every entry as unchanged.
|
|
237
|
+
#
|
|
238
|
+
# Run it on a connection of its own, and close that afterwards, as
|
|
239
|
+
# Tuber::Schedules#sync does: any other error partway can leave the server
|
|
240
|
+
# mid-sync on this socket with a reply unread.
|
|
241
|
+
#
|
|
242
|
+
# @param [String] owner The owner whose entries are replaced
|
|
243
|
+
# @param [Array<Tuber::Schedules::Entry>] entries The complete new set
|
|
244
|
+
# @return [Tuber::Schedules::SyncResult]
|
|
245
|
+
# @raise [Tuber::ScheduleSyncError] The server refused the sync
|
|
246
|
+
# @api private Use Tuber::Schedules#sync, which validates the entries
|
|
247
|
+
#
|
|
248
|
+
def schedule_sync(owner, entries)
|
|
249
|
+
_with_retry do
|
|
250
|
+
@mutex.synchronize do
|
|
251
|
+
_raise_not_connected! unless connection
|
|
252
|
+
|
|
253
|
+
cmd = "schedule-begin #{owner}"
|
|
254
|
+
reply = _round_trip(cmd)
|
|
255
|
+
raise ScheduleSyncError.new(reply[/\A\S*/], cmd, owner: owner) unless reply == "SYNCING #{owner}"
|
|
256
|
+
|
|
257
|
+
entries.each do |entry|
|
|
258
|
+
cmd = "schedule-put #{entry.line}"
|
|
259
|
+
reply = _round_trip(cmd, entry.body)
|
|
260
|
+
next if reply == 'STAGED'
|
|
261
|
+
|
|
262
|
+
# The refusal has already doomed the sync; the commit would only
|
|
263
|
+
# say ABORTED. Abort now so the error names the entry, and so the
|
|
264
|
+
# connection leaves sync mode before the caller sees it.
|
|
265
|
+
_round_trip('schedule-abort')
|
|
266
|
+
raise ScheduleSyncError.new(reply, cmd, owner: owner, entry: entry.name)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
status, *counts = _round_trip('schedule-commit').split(' ')
|
|
270
|
+
unless status == 'SYNCED' && counts.size == 4
|
|
271
|
+
raise ScheduleSyncError.new(status.to_s, 'schedule-commit', owner: owner)
|
|
272
|
+
end
|
|
273
|
+
Schedules::SyncResult.new(*counts.map(&:to_i))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
166
278
|
# Close connection with beanstalkd server.
|
|
167
279
|
#
|
|
168
280
|
# @example
|
|
@@ -170,11 +282,50 @@ class Tuber
|
|
|
170
282
|
#
|
|
171
283
|
def close
|
|
172
284
|
if @connection
|
|
173
|
-
|
|
285
|
+
begin
|
|
286
|
+
@connection.close
|
|
287
|
+
rescue StandardError
|
|
288
|
+
# A socket whose peer has already vanished can fail to close
|
|
289
|
+
# cleanly. It is gone either way, and #reconnect! must not raise
|
|
290
|
+
# on the way to replacing it.
|
|
291
|
+
end
|
|
174
292
|
@connection = nil
|
|
175
293
|
end
|
|
176
294
|
end
|
|
177
295
|
|
|
296
|
+
# Re-establishes this connection and replays its tube state onto the new
|
|
297
|
+
# socket: watched tubes with the weights they were watched with, the used
|
|
298
|
+
# tube, and the reserve mode. Safe to call whether the current socket is
|
|
299
|
+
# healthy, dropped, or already closed.
|
|
300
|
+
#
|
|
301
|
+
# This is the same healing the connection performs internally when a
|
|
302
|
+
# command notices the socket has died, exposed for consumers that hold a
|
|
303
|
+
# connection across an outage. Prefer it to building a new Tuber: a fresh
|
|
304
|
+
# Connection makes a single connect attempt by default (see
|
|
305
|
+
# Tuber::Configuration#connect_retries) and starts with no tube state, so
|
|
306
|
+
# the caller has to re-watch and re-weight everything by hand.
|
|
307
|
+
#
|
|
308
|
+
# @param [Integer, nil] tries Maximum number of connect attempts, nil for
|
|
309
|
+
# the configured default (see Tuber::Configuration#connect_retries)
|
|
310
|
+
# @param [Numeric, nil] retry_interval Seconds to wait between attempts,
|
|
311
|
+
# nil for the configured default
|
|
312
|
+
# @return [Tuber::Connection] self
|
|
313
|
+
# @raise [Tuber::NotConnected] Every connect attempt failed
|
|
314
|
+
# @example
|
|
315
|
+
# begin
|
|
316
|
+
# job = @conn.transmit("reserve")
|
|
317
|
+
# rescue Tuber::NotConnected
|
|
318
|
+
# @conn.reconnect! # watches, weights and reserve mode come back with it
|
|
319
|
+
# retry
|
|
320
|
+
# end
|
|
321
|
+
#
|
|
322
|
+
def reconnect!(tries: nil, retry_interval: nil)
|
|
323
|
+
_reconnect(tries: tries || _connect_tries,
|
|
324
|
+
retry_interval: retry_interval || config.connect_retry_interval)
|
|
325
|
+
_initialize_tubes
|
|
326
|
+
self
|
|
327
|
+
end
|
|
328
|
+
|
|
178
329
|
# Returns string representation of job.
|
|
179
330
|
#
|
|
180
331
|
# @example
|
|
@@ -185,15 +336,34 @@ class Tuber
|
|
|
185
336
|
end
|
|
186
337
|
alias :inspect :to_s
|
|
187
338
|
|
|
188
|
-
|
|
339
|
+
# Records a tube as watched, along with the weight it was watched with.
|
|
340
|
+
#
|
|
341
|
+
# @param [String] tube_name Name of the tube now being watched
|
|
342
|
+
# @param [Integer, nil] weight Weight passed to `watch`, nil for a plain watch
|
|
343
|
+
def add_to_watched(tube_name, weight = nil)
|
|
189
344
|
@tubes_watched << tube_name
|
|
190
|
-
@tubes_watched.uniq
|
|
345
|
+
@tubes_watched.uniq!
|
|
346
|
+
if weight
|
|
347
|
+
@tube_weights[tube_name] = weight
|
|
348
|
+
else
|
|
349
|
+
@tube_weights.delete(tube_name)
|
|
350
|
+
end
|
|
351
|
+
@tubes_watched
|
|
191
352
|
end
|
|
192
353
|
|
|
193
354
|
def remove_from_watched(tube_name)
|
|
355
|
+
@tube_weights.delete(tube_name)
|
|
194
356
|
@tubes_watched.delete(tube_name)
|
|
195
357
|
end
|
|
196
358
|
|
|
359
|
+
# Replaces the watched tube list, dropping weights for tubes that are no
|
|
360
|
+
# longer watched.
|
|
361
|
+
def tubes_watched=(tube_names)
|
|
362
|
+
@tubes_watched = tube_names
|
|
363
|
+
@tube_weights.keep_if { |name, _| @tubes_watched.include?(name) }
|
|
364
|
+
@tubes_watched
|
|
365
|
+
end
|
|
366
|
+
|
|
197
367
|
protected
|
|
198
368
|
|
|
199
369
|
# Establish a connection based on beanstalk address.
|
|
@@ -278,16 +448,35 @@ class Tuber
|
|
|
278
448
|
|
|
279
449
|
private
|
|
280
450
|
|
|
451
|
+
# Replays this connection's tube state onto a freshly established socket.
|
|
452
|
+
# Weights and reserve mode are per-connection server state that a new socket
|
|
453
|
+
# resets, so a reconnect that only re-watched the tube names would silently
|
|
454
|
+
# drop a weighted consumer back to FIFO. Order matters: watch, then drop
|
|
455
|
+
# default, then set the mode.
|
|
281
456
|
def _initialize_tubes
|
|
282
|
-
if @tubes_watched != ['default']
|
|
457
|
+
if @tubes_watched != ['default'] || @tube_weights.any?
|
|
283
458
|
tubes_watched.each do |t|
|
|
284
|
-
|
|
459
|
+
weight = @tube_weights[t]
|
|
460
|
+
transmit(weight ? "watch #{t} #{weight}" : "watch #{t}", init: false)
|
|
285
461
|
end
|
|
286
462
|
|
|
287
|
-
transmit("ignore default", init: false)
|
|
463
|
+
transmit("ignore default", init: false) unless @tubes_watched.include?('default')
|
|
288
464
|
end
|
|
289
465
|
|
|
290
466
|
transmit("use #{tube_used}", init: false) if @tube_used != 'default'
|
|
467
|
+
|
|
468
|
+
transmit("reserve-mode #{@reserve_mode}", init: false) if @reserve_mode && @reserve_mode.to_s != 'fifo'
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Writes one command, and a body framed as for put when given, and reads
|
|
472
|
+
# the one-line reply. Callers hold the mutex.
|
|
473
|
+
#
|
|
474
|
+
# @return [String] The reply line without its CRLF
|
|
475
|
+
def _round_trip(command, body = nil)
|
|
476
|
+
frame = "#{command}\r\n".b
|
|
477
|
+
frame << body.b << "\r\n" if body
|
|
478
|
+
connection.write(frame)
|
|
479
|
+
connection.readline.chomp
|
|
291
480
|
end
|
|
292
481
|
|
|
293
482
|
# Wrapper method for capturing certain failures and retry the payload block
|
|
@@ -304,7 +493,7 @@ class Tuber
|
|
|
304
493
|
yield
|
|
305
494
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE,
|
|
306
495
|
Errno::ECONNREFUSED => ex
|
|
307
|
-
_reconnect(
|
|
496
|
+
_reconnect(tries: _connect_tries, retry_interval: retry_interval)
|
|
308
497
|
_initialize_tubes if init
|
|
309
498
|
raise ex unless retransmit
|
|
310
499
|
retry
|
|
@@ -318,19 +507,37 @@ class Tuber
|
|
|
318
507
|
retry
|
|
319
508
|
end
|
|
320
509
|
|
|
321
|
-
#
|
|
510
|
+
# Connect attempts to allow when healing a connection that was already
|
|
511
|
+
# established. Never fewer than MAX_RETRIES, so the transparent retry
|
|
512
|
+
# behaves exactly as it always has by default; raising connect_retries
|
|
513
|
+
# lifts this with it, making one knob answer "how long should a client
|
|
514
|
+
# ride out a server restart" for cold starts and reconnects alike.
|
|
515
|
+
#
|
|
516
|
+
# @return [Integer] maximum number of connect attempts
|
|
517
|
+
def _connect_tries
|
|
518
|
+
[MAX_RETRIES, config.connect_retries.to_i + 1].max
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Drops the current socket and connects again.
|
|
322
522
|
#
|
|
323
|
-
# @param [Exception] original_exception The exception caused the retry
|
|
324
|
-
# @param [Integer] retry_interval The time to wait before the next reconnect
|
|
325
523
|
# @param [Integer] tries The maximum number of attempts to reconnect
|
|
326
|
-
|
|
524
|
+
# @param [Numeric] retry_interval The time to wait before the next attempt
|
|
525
|
+
# @raise [Tuber::NotConnected] Every attempt failed
|
|
526
|
+
def _reconnect(tries: MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL)
|
|
327
527
|
close
|
|
528
|
+
_connect(tries: tries, retry_interval: retry_interval)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Connects, retrying while the server is refusing or unreachable.
|
|
532
|
+
#
|
|
533
|
+
# @param [Integer] tries The maximum number of connect attempts
|
|
534
|
+
# @param [Numeric] retry_interval The time to wait between attempts
|
|
535
|
+
# @raise [Tuber::NotConnected] Every attempt failed
|
|
536
|
+
def _connect(tries: MAX_RETRIES, retry_interval: DEFAULT_RETRY_INTERVAL)
|
|
328
537
|
establish_connection
|
|
329
|
-
rescue
|
|
538
|
+
rescue *CONNECT_ERRORS => ex
|
|
330
539
|
tries -= 1
|
|
331
|
-
if tries
|
|
332
|
-
_raise_not_connected!
|
|
333
|
-
end
|
|
540
|
+
_raise_not_connected!(ex) if tries <= 0
|
|
334
541
|
sleep(retry_interval || DEFAULT_RETRY_INTERVAL)
|
|
335
542
|
retry
|
|
336
543
|
end
|
|
@@ -356,10 +563,23 @@ class Tuber
|
|
|
356
563
|
[sec, usec].pack('l_l_')
|
|
357
564
|
end
|
|
358
565
|
|
|
359
|
-
# Raises an error to be triggered when the connection has failed
|
|
566
|
+
# Raises an error to be triggered when the connection has failed.
|
|
567
|
+
#
|
|
568
|
+
# The underlying error is named in the message as well as kept as the
|
|
569
|
+
# exception's +cause+. A log line reading only "Connection to beanstalk
|
|
570
|
+
# '...' is closed!" hides whether the server refused, timed out, or never
|
|
571
|
+
# resolved — which is exactly what you want to know during an outage.
|
|
572
|
+
#
|
|
573
|
+
# Only the two callers that are genuinely inside a rescue pass a cause.
|
|
574
|
+
# Defaulting to +$!+ would be wrong: a command issued from inside someone
|
|
575
|
+
# else's rescue block would name whatever they were handling.
|
|
576
|
+
#
|
|
577
|
+
# @param [Exception, nil] cause The error that stopped the connect
|
|
360
578
|
# @raise [Tuber::NotConnected] Beanstalkd is no longer connected
|
|
361
|
-
def _raise_not_connected!
|
|
362
|
-
|
|
579
|
+
def _raise_not_connected!(cause = nil)
|
|
580
|
+
message = "Connection to beanstalk '#{@host}:#{@port}' is closed!"
|
|
581
|
+
message += " (#{cause.class}: #{cause.message})" if cause && !cause.is_a?(Tuber::NotConnected)
|
|
582
|
+
raise Tuber::NotConnected, message
|
|
363
583
|
end
|
|
364
584
|
|
|
365
585
|
end # Connection
|