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
data/lib/tuber/errors.rb
CHANGED
|
@@ -11,7 +11,7 @@ class Tuber
|
|
|
11
11
|
# Abstract class for errors that occur when a command does not complete successfully.
|
|
12
12
|
class UnexpectedResponse < RuntimeError
|
|
13
13
|
# Set of status states that are considered errors
|
|
14
|
-
ERROR_STATES = %w(OUT_OF_MEMORY INTERNAL_ERROR
|
|
14
|
+
ERROR_STATES = %w(OUT_OF_MEMORY OUT_OF_STORAGE INTERNAL_ERROR
|
|
15
15
|
BAD_FORMAT UNKNOWN_COMMAND JOB_TOO_BIG DRAINING
|
|
16
16
|
TIMED_OUT DEADLINE_SOON NOT_FOUND NOT_IGNORED EXPECTED_CRLF)
|
|
17
17
|
|
|
@@ -27,13 +27,14 @@ class Tuber
|
|
|
27
27
|
#
|
|
28
28
|
# @param [Tuber::UnexpectedResponse] status Unexpected response object
|
|
29
29
|
# @param [String] cmd Beanstalkd request command
|
|
30
|
+
# @param [String, nil] message Overrides the default "Response failed with: <status>"
|
|
30
31
|
#
|
|
31
32
|
# @example
|
|
32
33
|
# Tuber::UnexpectedResponse.new(NotFoundError, 'bury 123')
|
|
33
34
|
#
|
|
34
|
-
def initialize(status, cmd)
|
|
35
|
+
def initialize(status, cmd, message = nil)
|
|
35
36
|
@status, @cmd = status, cmd
|
|
36
|
-
super("Response failed with: #{status}")
|
|
37
|
+
super(message || "Response failed with: #{status}")
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# Translate beanstalkd error status to ruby Exeception
|
|
@@ -55,6 +56,8 @@ class Tuber
|
|
|
55
56
|
|
|
56
57
|
# Raises when the beanstalkd instance runs out of memory
|
|
57
58
|
class OutOfMemoryError < UnexpectedResponse; end
|
|
59
|
+
# Raises when a persistent Tuber server's disk budget (--max-storage-bytes) is spent
|
|
60
|
+
class OutOfStorageError < UnexpectedResponse; end
|
|
58
61
|
# Raises when the beanstalkd instance is draining and new jobs cannot be inserted
|
|
59
62
|
class DrainingError < UnexpectedResponse; end
|
|
60
63
|
# Raises when the job or tube cannot be found
|
|
@@ -75,4 +78,55 @@ class Tuber
|
|
|
75
78
|
class TimedOutError < UnexpectedResponse; end
|
|
76
79
|
# Raises when a tube could not be ignored because it is the last watched tube.
|
|
77
80
|
class NotIgnoredError < UnexpectedResponse; end
|
|
81
|
+
|
|
82
|
+
# Raises when a Tuber server refuses a schedule sync (see Tuber::Schedules#sync).
|
|
83
|
+
#
|
|
84
|
+
# Unless #applied? is true, nothing changed: the owner's previous schedule
|
|
85
|
+
# is still the one in effect.
|
|
86
|
+
class ScheduleSyncError < UnexpectedResponse
|
|
87
|
+
# @!attribute owner
|
|
88
|
+
# @return [String] the owner whose sync was refused
|
|
89
|
+
# @!attribute entry
|
|
90
|
+
# @return [String, nil] the entry tuber refused, nil when it refused the
|
|
91
|
+
# sync as a whole (on begin or commit)
|
|
92
|
+
attr_reader :owner, :entry
|
|
93
|
+
|
|
94
|
+
# @param [String] status The server's reply, e.g. "BAD_SCHEDULE"
|
|
95
|
+
# @param [String] cmd The command it refused
|
|
96
|
+
# @param [String] owner The owner being synced
|
|
97
|
+
# @param [String, nil] entry The refused entry's name
|
|
98
|
+
def initialize(status, cmd, owner:, entry: nil)
|
|
99
|
+
@owner, @entry = owner, entry
|
|
100
|
+
super(status, cmd, describe(status, cmd, owner, entry))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# True when the new set is live despite the error: the commit took effect
|
|
104
|
+
# in memory but tuber couldn't write it to disk (INTERNAL_ERROR). A tuber
|
|
105
|
+
# restart before the next successful sync brings back the previous set.
|
|
106
|
+
#
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def applied?
|
|
109
|
+
cmd == 'schedule-commit' && status == 'INTERNAL_ERROR'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def describe(status, cmd, owner, entry)
|
|
115
|
+
if entry
|
|
116
|
+
"tuber refused schedule #{entry.inspect} (#{status}): #{cmd}. " \
|
|
117
|
+
"Nothing changed: #{owner.inspect}'s previous schedule is still in effect."
|
|
118
|
+
elsif cmd == 'schedule-commit' && status == 'INTERNAL_ERROR'
|
|
119
|
+
"#{owner.inspect}'s new schedule is live, but tuber couldn't write it to disk " \
|
|
120
|
+
"(INTERNAL_ERROR). A tuber restart before the next successful sync brings back the previous one."
|
|
121
|
+
elsif cmd == 'schedule-commit'
|
|
122
|
+
"tuber refused #{owner.inspect}'s schedule on commit (#{status}). " \
|
|
123
|
+
"Nothing changed: the previous schedule is still in effect."
|
|
124
|
+
elsif status == 'UNKNOWN_COMMAND'
|
|
125
|
+
"this server has no recurring schedules (UNKNOWN_COMMAND to #{cmd.inspect}): " \
|
|
126
|
+
"it is beanstalkd, or a tuber older than the scheduler."
|
|
127
|
+
else
|
|
128
|
+
"tuber refused to begin a schedule sync for #{owner.inspect} (#{status})."
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
78
132
|
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
class Tuber
|
|
6
|
+
# Recurring schedules held by a Tuber server (beanstalkd has none): entries
|
|
7
|
+
# the server puts into a tube at the times a spec names, so an app needs no
|
|
8
|
+
# scheduler process of its own. A fire is an ordinary put — workers reserve
|
|
9
|
+
# the job as they would any other.
|
|
10
|
+
#
|
|
11
|
+
# An app owns its entries under a name of its choosing, the *owner*, and
|
|
12
|
+
# replaces the whole set with one #sync: entries it no longer sends are
|
|
13
|
+
# removed, an edited one is updated in place, and an unchanged one keeps its
|
|
14
|
+
# next fire. Every replica may sync; an identical set changes nothing.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# tuber.schedules.sync("splat") do |s|
|
|
18
|
+
# s.add "data_retention", tube: "splat.maintenance",
|
|
19
|
+
# cron: "0 2 * * *", zone: "Australia/Melbourne",
|
|
20
|
+
# body: { class: "DataRetentionJob", args: [] }.to_json, con: "maintenance"
|
|
21
|
+
# s.add "monitor_evaluation", tube: "splat.checkins", every: 60,
|
|
22
|
+
# idp: "monitor_evaluation", body: { class: "MonitorEvaluationJob", args: [] }.to_json
|
|
23
|
+
# end
|
|
24
|
+
# # => #<struct Tuber::Schedules::SyncResult added=2, changed=0, removed=0, unchanged=0>
|
|
25
|
+
#
|
|
26
|
+
class Schedules
|
|
27
|
+
# What a committed sync did to the owner's entries.
|
|
28
|
+
#
|
|
29
|
+
# If the connection dropped and the sync was re-run, the counts describe
|
|
30
|
+
# the last attempt only: a drop after the first attempt's commit landed
|
|
31
|
+
# makes the re-run report every entry as unchanged.
|
|
32
|
+
SyncResult = Struct.new(:added, :changed, :removed, :unchanged)
|
|
33
|
+
|
|
34
|
+
# One staged entry: its name, the `schedule-put` arguments, and the
|
|
35
|
+
# serialized body.
|
|
36
|
+
Entry = Struct.new(:name, :line, :body)
|
|
37
|
+
|
|
38
|
+
# Owners, entry names and tubes follow the server's tube-name rules.
|
|
39
|
+
NAME = %r{\A[A-Za-z0-9+/;.$_()][A-Za-z0-9\-+/;.$_()]*\z}
|
|
40
|
+
|
|
41
|
+
# @!attribute client
|
|
42
|
+
# @return [Tuber] returns the client instance
|
|
43
|
+
attr_reader :client
|
|
44
|
+
|
|
45
|
+
# @param [Tuber] client The tuber client instance.
|
|
46
|
+
def initialize(client)
|
|
47
|
+
@client = client
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Replaces +owner+'s entries with the ones the block adds, in one step.
|
|
51
|
+
#
|
|
52
|
+
# Send the complete schedule every time: an entry left out is deleted.
|
|
53
|
+
#
|
|
54
|
+
# Sync when a new release starts — a release step, or once at process
|
|
55
|
+
# boot — not on reconnect. Tuber keeps schedules across its own restarts
|
|
56
|
+
# (with -b), and during a rolling deploy an old process re-syncing would
|
|
57
|
+
# put back what the new release removed.
|
|
58
|
+
#
|
|
59
|
+
# @param [String] owner The app's name for its set of entries
|
|
60
|
+
# @param [Boolean] allow_empty A sync with no entries removes all of the
|
|
61
|
+
# owner's entries. Refused unless this is true, since an app that loaded
|
|
62
|
+
# an empty config by mistake would otherwise wipe its schedule.
|
|
63
|
+
# @yieldparam [Tuber::Schedules::Builder] s Call +s.add+ once per entry
|
|
64
|
+
# @return [Tuber::Schedules::SyncResult]
|
|
65
|
+
# @raise [Tuber::ScheduleSyncError] The server refused an entry or the
|
|
66
|
+
# sync. Nothing changed unless the error's #applied? says otherwise.
|
|
67
|
+
# @raise [ArgumentError] An entry is malformed, or the set is empty
|
|
68
|
+
# without +allow_empty+. Nothing is sent.
|
|
69
|
+
# @raise [Tuber::NotConnected] The sync's own connection couldn't be
|
|
70
|
+
# made (see Tuber::Configuration#connect_retries)
|
|
71
|
+
# @api public
|
|
72
|
+
def sync(owner, allow_empty: false)
|
|
73
|
+
owner = Schedules.name!("owner", owner)
|
|
74
|
+
builder = Builder.new
|
|
75
|
+
yield builder if block_given?
|
|
76
|
+
if builder.entries.empty? && !allow_empty
|
|
77
|
+
raise ArgumentError, "refusing to sync no entries for #{owner.inspect}: that removes " \
|
|
78
|
+
"all of its schedules. Pass allow_empty: true if that's meant."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A connection of its own: a blocking reserve on the client's connection
|
|
82
|
+
# can't hold the sync up, and an error partway, which can leave the
|
|
83
|
+
# server mid-sync with a reply unread, is cleaned up by closing it.
|
|
84
|
+
connection = Connection.new(client.connection.address)
|
|
85
|
+
begin
|
|
86
|
+
connection.schedule_sync(owner, builder.entries)
|
|
87
|
+
ensure
|
|
88
|
+
connection.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The entries the server holds, as [owner, name] pairs.
|
|
93
|
+
#
|
|
94
|
+
# @param [String, nil] owner Only this owner's entries
|
|
95
|
+
# @return [Array<Array(String, String)>]
|
|
96
|
+
# @example
|
|
97
|
+
# tuber.schedules.list("splat") # => [["splat", "data_retention"], ["splat", "monitor_evaluation"]]
|
|
98
|
+
# @api public
|
|
99
|
+
def list(owner = nil)
|
|
100
|
+
owner = Schedules.name!("owner", owner) if owner
|
|
101
|
+
yaml = client.connection.transmit_raw(owner ? "list-schedules #{owner}" : "list-schedules")
|
|
102
|
+
yaml.each_line.grep(/\A- /).map { |line| line.chomp[2..-1].split(' ', 2) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One entry's definition, next three fires, last fire and counters.
|
|
106
|
+
#
|
|
107
|
+
# Times are Time objects at the entry's own zone offset, nil when there is
|
|
108
|
+
# none yet; counters are Integers. +buried_streak+ above 0 means fires are
|
|
109
|
+
# deduplicating against a buried job (through +idp:+) and queueing nothing.
|
|
110
|
+
#
|
|
111
|
+
# @param [String] owner The entry's owner
|
|
112
|
+
# @param [String] name The entry's name
|
|
113
|
+
# @return [Tuber::StatStruct, nil] nil if there is no such entry
|
|
114
|
+
# @example
|
|
115
|
+
# stats = tuber.schedules.stats("splat", "data_retention")
|
|
116
|
+
# stats.next_fire # => 2026-10-12 02:00:00 +1100
|
|
117
|
+
# stats["next-fire-2"] # => 2026-10-13 02:00:00 +1100
|
|
118
|
+
# stats.buried_streak # => 0
|
|
119
|
+
# @api public
|
|
120
|
+
def stats(owner, name)
|
|
121
|
+
owner = Schedules.name!("owner", owner)
|
|
122
|
+
name = Schedules.name!("name", name)
|
|
123
|
+
yaml = client.connection.transmit_raw("stats-schedule #{owner} #{name}")
|
|
124
|
+
stats = yaml.each_line.with_object({}) do |line, hash|
|
|
125
|
+
key, value = line.chomp.split(': ', 2)
|
|
126
|
+
hash[key] = Schedules.stat_value(value) if value
|
|
127
|
+
end
|
|
128
|
+
StatStruct.from_hash(stats)
|
|
129
|
+
rescue Tuber::NotFoundError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# String representation of the schedules collection.
|
|
134
|
+
def to_s
|
|
135
|
+
"#<Tuber::Schedules>"
|
|
136
|
+
end
|
|
137
|
+
alias :inspect :to_s
|
|
138
|
+
|
|
139
|
+
# Checks an owner, entry name or tube against tube-name rules.
|
|
140
|
+
#
|
|
141
|
+
# @return [String] The name, a Symbol converted
|
|
142
|
+
# @raise [ArgumentError] It breaks them
|
|
143
|
+
# @api private
|
|
144
|
+
def self.name!(what, value)
|
|
145
|
+
value = value.to_s if value.is_a?(Symbol)
|
|
146
|
+
return value if value.is_a?(String) && value.bytesize <= 200 && value.match?(NAME)
|
|
147
|
+
raise ArgumentError, "invalid schedule #{what} #{value.inspect}: up to 200 bytes of letters, " \
|
|
148
|
+
"digits and - + / ; . $ _ ( ), not starting with -"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Converts one stats-schedule value by how tuber writes it: a quoted value
|
|
152
|
+
# is a String, `""` meaning none (nil); bare digits are a counter; any
|
|
153
|
+
# other bare value is a time.
|
|
154
|
+
#
|
|
155
|
+
# @api private
|
|
156
|
+
def self.stat_value(value)
|
|
157
|
+
if value.start_with?('"')
|
|
158
|
+
string = value.delete_prefix('"').delete_suffix('"')
|
|
159
|
+
string.empty? ? nil : string
|
|
160
|
+
elsif value.match?(/\A\d+\z/)
|
|
161
|
+
value.to_i
|
|
162
|
+
else
|
|
163
|
+
begin
|
|
164
|
+
Time.iso8601(value)
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
value # a field newer than this gem; better raw than an error
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Collects the entries of one sync. Nothing is sent until the block ends,
|
|
172
|
+
# and nothing at all if an #add raises.
|
|
173
|
+
class Builder
|
|
174
|
+
# @!attribute entries
|
|
175
|
+
# @return [Array<Tuber::Schedules::Entry>] the entries added so far
|
|
176
|
+
attr_reader :entries
|
|
177
|
+
|
|
178
|
+
def initialize
|
|
179
|
+
@entries = []
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Adds one entry. Give exactly one of +cron:+, +every:+ or +spec:+.
|
|
183
|
+
#
|
|
184
|
+
# @param [String] name The entry's name, unique within the owner. Keep
|
|
185
|
+
# it stable: a renamed entry is a removal plus an addition.
|
|
186
|
+
# @param [String] tube The tube each fire puts into
|
|
187
|
+
# @param [String] body The job body each fire puts, passed through
|
|
188
|
+
# Tuber.configuration.job_serializer as for Tube#put
|
|
189
|
+
# @param [String] cron 5-field cron (`"0 2 * * *"`) or a macro
|
|
190
|
+
# (`"@daily"`), optionally followed by an IANA zone
|
|
191
|
+
# @param [String] zone IANA zone for +cron+ (default UTC)
|
|
192
|
+
# @param [Integer, String] every Interval, aligned to the Unix epoch:
|
|
193
|
+
# seconds, or `"10m"`, `"1h"`, `"1d"`
|
|
194
|
+
# @param [Integer, String] offset Shift for +every+, e.g. `"5m"`
|
|
195
|
+
# @param [String] spec The spec as tuber reads it, instead of the above
|
|
196
|
+
# @param [Integer] pri Priority; defaults as for a put
|
|
197
|
+
# @param [Integer] delay Delay of each fire's job; defaults as for a put
|
|
198
|
+
# @param [Integer] ttr TTR of each fire's job; defaults as for a put
|
|
199
|
+
# @param [String, Array] idp Idempotency key, or [key, ttl], as for a
|
|
200
|
+
# put. A fire is skipped while the previous fire's job is still in
|
|
201
|
+
# the tube — buried included, so a buried job stops the entry until
|
|
202
|
+
# it is kicked or deleted.
|
|
203
|
+
# @param [String, Array] con Concurrency key, or [key, limit], as for a put
|
|
204
|
+
# @param [String] grp Group key, as for a put
|
|
205
|
+
# @param [String] aft After-group key, as for a put
|
|
206
|
+
# @param [Symbol, Integer, false] catchup What a tick missed while
|
|
207
|
+
# tuber was down does: `:once` (the default) fires once for the gap;
|
|
208
|
+
# an Integer fires that once only if it is at most that many seconds
|
|
209
|
+
# stale; `:never`, `false` or 0 never fires it.
|
|
210
|
+
# @return [self]
|
|
211
|
+
# @raise [ArgumentError] Something tuber would refuse, or that would
|
|
212
|
+
# break the line
|
|
213
|
+
def add(name, tube:, body:, cron: nil, zone: nil, every: nil, offset: nil, spec: nil,
|
|
214
|
+
pri: nil, delay: nil, ttr: nil, idp: nil, con: nil, grp: nil, aft: nil, catchup: :once)
|
|
215
|
+
name = Schedules.name!("name", name)
|
|
216
|
+
tube = Schedules.name!("tube", tube)
|
|
217
|
+
raise ArgumentError, "schedule #{name.inspect} added twice" if @entries.any? { |e| e.name == name }
|
|
218
|
+
|
|
219
|
+
config = Tuber.configuration
|
|
220
|
+
serialized = config.job_serializer.call(body)
|
|
221
|
+
unless serialized.is_a?(String)
|
|
222
|
+
raise ArgumentError, "the body of schedule #{name.inspect} is a #{serialized.class}, not a String: " \
|
|
223
|
+
"serialize it, or set Tuber.configuration.job_serializer"
|
|
224
|
+
end
|
|
225
|
+
serialized = serialized.b
|
|
226
|
+
|
|
227
|
+
words = [name, tube,
|
|
228
|
+
Integer(pri || config.default_put_pri),
|
|
229
|
+
Integer(delay || config.default_put_delay),
|
|
230
|
+
Integer(ttr || config.default_put_ttr),
|
|
231
|
+
serialized.bytesize]
|
|
232
|
+
words.concat(tags(name, idp: idp, con: con, grp: grp, aft: aft, catchup: catchup))
|
|
233
|
+
words << spec_text(name, cron: cron, zone: zone, every: every, offset: offset, spec: spec)
|
|
234
|
+
@entries << Entry.new(name, words.join(' '), serialized)
|
|
235
|
+
self
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def spec_text(name, cron:, zone:, every:, offset:, spec:)
|
|
241
|
+
given = { spec: spec, cron: cron, every: every }.compact
|
|
242
|
+
unless given.size == 1
|
|
243
|
+
raise ArgumentError, "schedule #{name.inspect} needs exactly one of cron:, every: or spec: " \
|
|
244
|
+
"(got #{given.empty? ? 'none' : given.keys.join(', ')})"
|
|
245
|
+
end
|
|
246
|
+
raise ArgumentError, "schedule #{name.inspect}: zone: goes with cron:" if zone && !cron
|
|
247
|
+
raise ArgumentError, "schedule #{name.inspect}: offset: goes with every:" if offset && !every
|
|
248
|
+
|
|
249
|
+
text = if every
|
|
250
|
+
["every #{duration(every)}", (offset && "offset #{duration(offset)}")].compact.join(' ')
|
|
251
|
+
elsif cron
|
|
252
|
+
[cron, zone].compact.join(' ')
|
|
253
|
+
else
|
|
254
|
+
spec
|
|
255
|
+
end
|
|
256
|
+
# Tags end at the first word without a colon, so a spec can't have one.
|
|
257
|
+
if !text.is_a?(String) || text.strip.empty? || text.match?(/[\r\n:]/)
|
|
258
|
+
raise ArgumentError, "invalid spec #{text.inspect} for schedule #{name.inspect}"
|
|
259
|
+
end
|
|
260
|
+
text.strip
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def duration(value)
|
|
264
|
+
value.is_a?(Integer) ? "#{value.to_i}s" : value.to_s
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def tags(name, catchup:, **options)
|
|
268
|
+
tags = Tube.tags(options)
|
|
269
|
+
case catchup
|
|
270
|
+
when :once, nil then nil
|
|
271
|
+
when :never, false then tags << "catchup:0"
|
|
272
|
+
when Integer
|
|
273
|
+
raise ArgumentError, "catchup: can't be negative (schedule #{name.inspect})" if catchup.negative?
|
|
274
|
+
tags << "catchup:#{catchup.to_i}"
|
|
275
|
+
else
|
|
276
|
+
raise ArgumentError, "invalid catchup: #{catchup.inspect} for schedule #{name.inspect} " \
|
|
277
|
+
"(:once, :never, or a number of seconds)"
|
|
278
|
+
end
|
|
279
|
+
tags.each do |tag|
|
|
280
|
+
next if tag.match?(/\A[a-z]+:\S+\z/)
|
|
281
|
+
raise ArgumentError, "invalid tag #{tag.inspect} for schedule #{name.inspect}: " \
|
|
282
|
+
"keys can't be empty or contain whitespace"
|
|
283
|
+
end
|
|
284
|
+
tags
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end # Schedules
|
|
288
|
+
end # Tuber
|
|
@@ -103,7 +103,11 @@ class Tuber
|
|
|
103
103
|
#
|
|
104
104
|
# @api public
|
|
105
105
|
def reserve_mode(mode)
|
|
106
|
-
transmit("reserve-mode #{mode}")
|
|
106
|
+
res = transmit("reserve-mode #{mode}")
|
|
107
|
+
# Remembered so a reconnect can replay it: reserve mode is per-connection
|
|
108
|
+
# server state, and a fresh socket starts back at fifo.
|
|
109
|
+
client.connection.reserve_mode = mode
|
|
110
|
+
res
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
# Reserves a specific job by its ID.
|
|
@@ -202,7 +206,7 @@ class Tuber
|
|
|
202
206
|
names.each do |t|
|
|
203
207
|
cmd = weight ? "watch #{t} #{weight}" : "watch #{t}"
|
|
204
208
|
transmit cmd
|
|
205
|
-
client.connection.add_to_watched(t)
|
|
209
|
+
client.connection.add_to_watched(t, weight)
|
|
206
210
|
end
|
|
207
211
|
rescue BadFormatError => ex
|
|
208
212
|
raise InvalidTubeName, "Tube in '#{ex.cmd}' is invalid!"
|
data/lib/tuber/tube/record.rb
CHANGED
|
@@ -62,21 +62,32 @@ class Tuber
|
|
|
62
62
|
|
|
63
63
|
cmd_options = "#{options[:pri]} #{options[:delay]} #{options[:ttr]} #{serialized_body.bytesize}"
|
|
64
64
|
|
|
65
|
-
tags =
|
|
66
|
-
if options[:idp]
|
|
67
|
-
tags << (options[:idp].is_a?(Array) ? "idp:#{options[:idp].join(':')}" : "idp:#{options[:idp]}")
|
|
68
|
-
end
|
|
69
|
-
if options[:con]
|
|
70
|
-
tags << (options[:con].is_a?(Array) ? "con:#{options[:con].join(':')}" : "con:#{options[:con]}")
|
|
71
|
-
end
|
|
72
|
-
tags << "grp:#{options[:grp]}" if options[:grp]
|
|
73
|
-
tags << "aft:#{options[:aft]}" if options[:aft]
|
|
74
|
-
|
|
65
|
+
tags = Tube.tags(options)
|
|
75
66
|
cmd_options = "#{cmd_options} #{tags.join(' ')}" if tags.any?
|
|
76
67
|
transmit("put #{cmd_options}\r\n#{serialized_body}")
|
|
77
68
|
end
|
|
78
69
|
end
|
|
79
70
|
|
|
71
|
+
# The tags a put carries for its :idp, :con, :grp and :aft options.
|
|
72
|
+
# Tuber::Schedules builds its entries' tags here too: every fire is a put
|
|
73
|
+
# carrying them.
|
|
74
|
+
#
|
|
75
|
+
# @param [Hash] options Put options, as for #put
|
|
76
|
+
# @return [Array<String>] e.g. ["idp:report:300", "con:api"]
|
|
77
|
+
# @api private
|
|
78
|
+
def self.tags(options)
|
|
79
|
+
tags = []
|
|
80
|
+
if options[:idp]
|
|
81
|
+
tags << (options[:idp].is_a?(Array) ? "idp:#{options[:idp].join(':')}" : "idp:#{options[:idp]}")
|
|
82
|
+
end
|
|
83
|
+
if options[:con]
|
|
84
|
+
tags << (options[:con].is_a?(Array) ? "con:#{options[:con].join(':')}" : "con:#{options[:con]}")
|
|
85
|
+
end
|
|
86
|
+
tags << "grp:#{options[:grp]}" if options[:grp]
|
|
87
|
+
tags << "aft:#{options[:aft]}" if options[:aft]
|
|
88
|
+
tags
|
|
89
|
+
end
|
|
90
|
+
|
|
80
91
|
# Peek at next job within this tube in given `state`.
|
|
81
92
|
#
|
|
82
93
|
# @param [String] state The job state to peek at (`ready`, `buried`, `delayed`)
|
data/lib/tuber/version.rb
CHANGED
data/lib/tuber.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'thread' unless defined?(Mutex)
|
|
4
4
|
|
|
5
|
-
%w(version configuration errors connection tube job stats).each do |f|
|
|
5
|
+
%w(version configuration errors connection tube job stats schedules).each do |f|
|
|
6
6
|
require "tuber/#{f}"
|
|
7
7
|
end
|
|
8
8
|
|
|
@@ -51,6 +51,15 @@ class Tuber
|
|
|
51
51
|
@stats ||= Stats.new(self)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Returns Tuber::Schedules object for the server's recurring schedules
|
|
55
|
+
# (Tuber only).
|
|
56
|
+
#
|
|
57
|
+
# @return [Tuber::Schedules] schedules object
|
|
58
|
+
# @api public
|
|
59
|
+
def schedules
|
|
60
|
+
@schedules ||= Schedules.new(self)
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
# Puts the server into drain mode, rejecting new puts.
|
|
55
64
|
#
|
|
56
65
|
# @return [Hash] Response from beanstalkd
|
|
@@ -71,6 +80,27 @@ class Tuber
|
|
|
71
80
|
connection.transmit("undrain")
|
|
72
81
|
end
|
|
73
82
|
|
|
83
|
+
# Re-establishes the connection and replays its tube state: watched tubes
|
|
84
|
+
# with their weights, the used tube, and the reserve mode.
|
|
85
|
+
#
|
|
86
|
+
# Use this rather than building a new Tuber when a connection drops — a new
|
|
87
|
+
# client starts with no tube state and, by default, gets a single connect
|
|
88
|
+
# attempt, so it loses a race a reconnect would have won.
|
|
89
|
+
#
|
|
90
|
+
# @param [Integer, nil] tries Maximum number of connect attempts, nil for the
|
|
91
|
+
# configured default (see Tuber::Configuration#connect_retries)
|
|
92
|
+
# @param [Numeric, nil] retry_interval Seconds between attempts, nil for the
|
|
93
|
+
# configured default
|
|
94
|
+
# @return [Tuber] self
|
|
95
|
+
# @raise [Tuber::NotConnected] Every connect attempt failed
|
|
96
|
+
# @example
|
|
97
|
+
# @tuber_instance.reconnect!
|
|
98
|
+
#
|
|
99
|
+
def reconnect!(tries: nil, retry_interval: nil)
|
|
100
|
+
connection.reconnect!(tries: tries, retry_interval: retry_interval)
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
74
104
|
# Closes the related connection
|
|
75
105
|
#
|
|
76
106
|
# @example
|