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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bf9725eaee223b9a587d41a70472161997c0792a7e3ad7917488e8e5dbacb62
4
- data.tar.gz: 4759d84c9fae6eda87ed77f26817b6eae8d88fb2d80addf98723530c99551f7b
3
+ metadata.gz: 3dad07a18da118a0c3f50cdb00de169fbc73c4691e7484c422d98bccd12b750e
4
+ data.tar.gz: eeb2247e67cf72d407c578f9a0e2e61820ebc3b3594ce6da39f90eb312ef23d4
5
5
  SHA512:
6
- metadata.gz: 712f68857c4fcbedeaee298e94499abc9134337b1cd30de8f32c6a77fc8e640062bd6b1f0ba45122031d79a3c85cba7622c228f2d4447427e0d90c95668c6c47
7
- data.tar.gz: 6bc153d12ffc33d3dcdd772ea31e8b2675a672779ace7812748bbd00c64346382bfea7c7d843ed0a24e610117f638f81445e0696dcfffa11633d93cc275cccd4
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
- gem 'redcarpet', '~> 1'
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
 
@@ -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
@@ -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
- attr_accessor :tubes_watched, :tube_used
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
- establish_connection
71
- rescue
72
- _raise_not_connected!
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
- @connection.close
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
- def add_to_watched(tube_name)
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
- transmit("watch #{t}", init: false)
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(ex, retry_interval)
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
- # Tries to re-establish connection to the beanstalkd
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
- def _reconnect(original_exception, retry_interval, tries=MAX_RETRIES)
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 Errno::ECONNREFUSED
538
+ rescue *CONNECT_ERRORS => ex
330
539
  tries -= 1
331
- if tries.zero?
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
- raise Tuber::NotConnected, "Connection to beanstalk '#{@host}:#{@port}' is closed!"
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