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.
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!"
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Tuber
4
4
  # Current version of gem.
5
- VERSION = "0.5.1"
5
+ VERSION = "0.7.0"
6
6
  end
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