tuber 0.0.1 → 0.5.1

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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tuber
4
+ # Exception to stop processing jobs during a `process!` loop.
5
+ # Simply `raise AbortProcessingError` in any job process handler to stop the processing loop.
6
+ class AbortProcessingError < RuntimeError; end
7
+
8
+ # Represents collection of job-related commands.
9
+ class Jobs
10
+
11
+ # @!attribute processors
12
+ # @return [Array<Proc>] returns Collection of proc to handle beanstalkd jobs
13
+ # @!attribute client
14
+ # @return [Tuber] returns the client instance
15
+ # @!attribute current_job
16
+ # @return [Tuber] returns the currently processing job in the process loop
17
+ attr_reader :processors, :client, :current_job
18
+
19
+ # Number of retries to process a job.
20
+ MAX_RETRIES = 3
21
+
22
+ # Delay in seconds before to make job ready again.
23
+ RELEASE_DELAY = 1
24
+
25
+ # Number of seconds to wait for a job before checking a different server.
26
+ RESERVE_TIMEOUT = nil
27
+
28
+ # Creates new jobs instance.
29
+ #
30
+ # @param [Tuber] client The tuber client instance.
31
+ # @example
32
+ # Tuber::Jobs.new(@client)
33
+ #
34
+ def initialize(client)
35
+ @client = client
36
+ end
37
+
38
+ # Delegates transmit to the connection object.
39
+ #
40
+ # @see Tuber::Connection#transmit
41
+ def transmit(command, options={})
42
+ # Empty **options must not be forwarded: on Ruby 2.7 it arrives as an
43
+ # extra positional {} at the receiver.
44
+ return client.connection.transmit(command) if options.empty?
45
+ client.connection.transmit(command, **options)
46
+ end
47
+
48
+ # Peek (or find) job by id from beanstalkd.
49
+ #
50
+ # @param [Integer] id Job id to find
51
+ # @return [Tuber::Job] Job matching given id
52
+ # @example
53
+ # @tuber.jobs[123] # => <Tuber::Job>
54
+ # @tuber.jobs.find(123) # => <Tuber::Job>
55
+ # @tuber.jobs.peek(123) # => <Tuber::Job>
56
+ #
57
+ # @api public
58
+ def find(id)
59
+ res = transmit("peek #{id}")
60
+ Job.new(client, res)
61
+ rescue Tuber::NotFoundError
62
+ nil
63
+ end
64
+ alias_method :peek, :find
65
+ alias_method :[], :find
66
+
67
+ # Extends the ttr of every job currently reserved by this connection.
68
+ #
69
+ # A single heartbeat for a whole +reserve_batch+ window: the server already
70
+ # tracks the reserved set per connection, so no ids are sent and jobs that
71
+ # were already deleted, released, buried or lost to a ttr timeout are simply
72
+ # absent. Each job keeps its own ttr; deadlines are extended individually.
73
+ #
74
+ # The returned count is how many jobs the connection *actually* still holds.
75
+ # If it is lower than expected, jobs hit their ttr and went back to the queue
76
+ # while the worker was busy.
77
+ #
78
+ # @return [Integer] Number of held jobs whose deadline was extended
79
+ # @example
80
+ # @tuber.jobs.touch_all # => 10
81
+ #
82
+ # @api public
83
+ def touch_all
84
+ transmit("touch-all")[:id].to_i
85
+ end
86
+
87
+ # Register a processor to handle beanstalkd job on particular tube.
88
+ #
89
+ # @param [String] tube_name Tube name
90
+ # @param [Hash{String=>RuntimeError}] options settings for processor
91
+ # @param [Proc] block Process beanstalkd job
92
+ # @option options [Integer] max_retries Number of retries to process a job
93
+ # @option options [Array<RuntimeError>] retry_on Collection of errors to rescue and re-run processor
94
+ #
95
+ # @example
96
+ # @beanstalk.jobs.register('some-tube', :retry_on => [SomeError]) do |job|
97
+ # do_something(job)
98
+ # end
99
+ #
100
+ # @beanstalk.jobs.register('other-tube') do |job|
101
+ # do_something_else(job)
102
+ # end
103
+ #
104
+ # @api public
105
+ def register(tube_name, options={}, &block)
106
+ @processors ||= {}
107
+ max_retries = options[:max_retries] || MAX_RETRIES
108
+ retry_on = Array(options[:retry_on])
109
+ @processors[tube_name.to_s] = { :block => block, :retry_on => retry_on, :max_retries => max_retries }
110
+ end
111
+
112
+ # Sets flag to indicate that process loop should stop after current job
113
+ def stop!
114
+ @stop = true
115
+ end
116
+
117
+ # Returns whether the process loop should stop
118
+ #
119
+ # @return [Boolean] if true the loop should stop after current processing
120
+ def stop?
121
+ !!@stop
122
+ end
123
+
124
+ # Watch, reserve, process and delete or bury or release jobs.
125
+ #
126
+ # @param [Hash{String => Integer}] options Settings for processing
127
+ # @option options [Integer] release_delay Delay in seconds before to make job ready again
128
+ # @option options [Integer] reserve_timeout Number of seconds to wait for a job before checking a different server
129
+ #
130
+ # @api public
131
+ def process!(options={})
132
+ release_delay = options.delete(:release_delay) || RELEASE_DELAY
133
+ reserve_timeout = options.delete(:reserve_timeout) || RESERVE_TIMEOUT
134
+ client.tubes.watch!(*processors.keys)
135
+ while !stop? do
136
+ begin
137
+ @current_job = client.tubes.reserve(reserve_timeout)
138
+ processor = processors[@current_job.tube]
139
+ begin
140
+ processor[:block].call(@current_job)
141
+ @current_job.delete
142
+ rescue *processor[:retry_on]
143
+ if @current_job.stats.releases < processor[:max_retries]
144
+ @current_job.release(:delay => release_delay)
145
+ end
146
+ end
147
+ rescue AbortProcessingError
148
+ break
149
+ rescue Tuber::JobNotReserved, Tuber::NotFoundError, Tuber::TimedOutError
150
+ retry
151
+ rescue StandardError # handles unspecified errors
152
+ @current_job.bury if @current_job
153
+ ensure # bury if still reserved
154
+ @current_job.bury if @current_job && @current_job.exists? && @current_job.reserved?
155
+ @current_job = nil
156
+ end
157
+ end
158
+ end # process!
159
+ end # Jobs
160
+ end # Tuber
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tuber
4
+ # Represents job related commands.
5
+ class Job
6
+
7
+ # @!attribute id
8
+ # @return [Integer] id for the job.
9
+ # @!attribute body
10
+ # @return [String] the job's body.
11
+ # @!attribute reserved
12
+ # @return [Boolean] whether the job has been reserved.
13
+ # @!attribute client
14
+ # @return [Tuber] returns the client instance
15
+ attr_reader :id, :body, :reserved, :client
16
+
17
+
18
+ # Initializes a new job object.
19
+ #
20
+ # @param [Hash{Symbol => String,Number}] res Result from beanstalkd response
21
+ #
22
+ def initialize(client, res)
23
+ @client = client
24
+ @id = res[:id]
25
+ @body = res[:body]
26
+ @reserved = res[:status] == 'RESERVED'
27
+ end
28
+
29
+ # Sends command to bury a reserved job.
30
+ #
31
+ # @param [Hash{Symbol => Integer}] options Settings to bury job
32
+ # @option options [Integer] pri Assign new priority to job
33
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
34
+ #
35
+ # @example
36
+ # @job.bury({:pri => 100})
37
+ # # => {:status=>"BURIED", :body=>nil}
38
+ #
39
+ # @api public
40
+ def bury(options={})
41
+ options = { :pri => stats.pri }.merge(options)
42
+ with_reserved("bury #{id} #{options[:pri]}") do
43
+ @reserved = false
44
+ end
45
+ end
46
+
47
+ # Sends command to release a job back to ready state.
48
+ #
49
+ # @param [Hash{String => Integer}] options Settings to release job
50
+ # @option options [Integer] pri Assign new priority to job
51
+ # @option options [Integer] delay Assign new delay to job
52
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
53
+ # @example
54
+ # @tuber.jobs.find(123).release(:pri => 10, :delay => 5)
55
+ # # => {:status=>"RELEASED", :body=>nil}
56
+ #
57
+ # @api public
58
+ def release(options={})
59
+ options = { :pri => stats.pri, :delay => stats.delay }.merge(options)
60
+ with_reserved("release #{id} #{options[:pri]} #{options[:delay]}") do
61
+ @reserved = false
62
+ end
63
+ end
64
+
65
+ # Sends command to touch job which extends the ttr.
66
+ #
67
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
68
+ # @example
69
+ # @tuber.jobs.find(123).touch
70
+ # # => {:status=>"TOUCHED", :body=>nil}
71
+ #
72
+ # @api public
73
+ def touch
74
+ with_reserved("touch #{id}")
75
+ end
76
+
77
+ # Sends command to delete a job.
78
+ #
79
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
80
+ # @example
81
+ # @tuber.jobs.find(123).delete
82
+ # # => {:status=>"DELETED", :body=>nil}
83
+ #
84
+ # @api public
85
+ def delete
86
+ transmit("delete #{id}") { @reserved = false }
87
+ end
88
+
89
+ # Sends command to kick a buried job.
90
+ #
91
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
92
+ # @example
93
+ # @tuber.jobs.find(123).kick
94
+ # # => {:status=>"KICKED", :body=>nil}
95
+ #
96
+ # @api public
97
+ def kick
98
+ transmit("kick-job #{id}")
99
+ end
100
+
101
+ # Sends command to get stats about job.
102
+ #
103
+ # @return [Tuber::StatStruct] struct filled with relevant job stats
104
+ # @example
105
+ # @tuber.jobs.find(123).stats
106
+ # @job.stats.tube # => "some-tube"
107
+ #
108
+ # @api public
109
+ def stats
110
+ res = transmit("stats-job #{id}")
111
+ StatStruct.from_hash(res[:body])
112
+ end
113
+
114
+ # Check if job is currently in a reserved state.
115
+ #
116
+ # @return [Boolean] Returns true if the job is in a reserved state
117
+ # @example
118
+ # @tuber.jobs.find(123).reserved?
119
+ #
120
+ # @api public
121
+ def reserved?
122
+ @reserved || self.stats.state == "reserved"
123
+ end
124
+
125
+ # Check if the job still exists.
126
+ #
127
+ # @return [Boolean] Returns true if the job still exists
128
+ # @example
129
+ # @tuber.jobs.find(123).exists?
130
+ #
131
+ # @api public
132
+ def exists?
133
+ !self.stats.nil?
134
+ rescue Tuber::NotFoundError
135
+ false
136
+ end
137
+
138
+ # Returns the name of the tube this job is in
139
+ #
140
+ # @return [String] The name of the tube for this job
141
+ # @example
142
+ # @tuber.jobs.find(123).tube
143
+ # # => "some-tube"
144
+ #
145
+ # @api public
146
+ def tube
147
+ @tube ||= self.stats.tube
148
+ end
149
+
150
+ # Returns the ttr of this job
151
+ #
152
+ # @return [Integer] The ttr of this job
153
+ # @example
154
+ # @tuber.jobs.find(123).ttr
155
+ # # => 123
156
+ #
157
+ # @api public
158
+ def ttr
159
+ @ttr ||= self.stats.ttr
160
+ end
161
+
162
+ # Returns the pri of this job
163
+ #
164
+ # @return [Integer] The pri of this job
165
+ # @example
166
+ # @tuber.jobs.find(123).pri
167
+ # # => 1
168
+ #
169
+ def pri
170
+ self.stats.pri
171
+ end
172
+
173
+ # Returns the delay of this job
174
+ #
175
+ # @return [Integer] The delay of this job
176
+ # @example
177
+ # @tuber.jobs.find(123).delay
178
+ # # => 5
179
+ #
180
+ def delay
181
+ self.stats.delay
182
+ end
183
+
184
+ # Returns string representation of job
185
+ #
186
+ # @return [String] string representation
187
+ # @example
188
+ # @tuber.jobs.find(123).to_s
189
+ # @tuber.jobs.find(123).inspect
190
+ #
191
+ def to_s
192
+ "#<Tuber::Job id=#{id} body=#{body.inspect}>"
193
+ end
194
+ alias :inspect :to_s
195
+
196
+ protected
197
+
198
+ # Transmit command to beanstalkd instance and fetch response.
199
+ #
200
+ # @param [String] cmd Beanstalkd command to send.
201
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
202
+ # @example
203
+ # transmit('stats')
204
+ # transmit('stats') { 'success' }
205
+ #
206
+ def transmit(cmd, &block)
207
+ res = client.connection.transmit(cmd)
208
+ yield if block_given?
209
+ res
210
+ end
211
+
212
+ # Transmits a command which requires the job to be reserved.
213
+ #
214
+ # @param [String] cmd Beanstalkd command to send.
215
+ # @return [Hash{Symbol => String,Number}] Beanstalkd response for the command.
216
+ # @raise [Tuber::JobNotReserved] Command cannot execute since job is not reserved.
217
+ # @example
218
+ # with_reserved("bury 26") { @reserved = false }
219
+ #
220
+ def with_reserved(cmd, &block)
221
+ raise JobNotReserved unless reserved?
222
+ transmit(cmd, &block)
223
+ end
224
+
225
+ end # Job
226
+ end # Tuber
data/lib/tuber/job.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tuber/job/record'
4
+ require 'tuber/job/collection'
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tuber
4
+ #
5
+ # Borrowed from:
6
+ # https://github.com/dolzenko/faster_open_struct/blob/master/lib/faster_open_struct.rb
7
+ #
8
+ # Up to 40 (!) times more memory efficient version of OpenStruct
9
+ #
10
+ # Differences from Ruby MRI OpenStruct:
11
+ #
12
+ # 1. Doesn't `dup` passed initialization hash (NOTE: only reference to hash is stored)
13
+ #
14
+ # 2. Doesn't convert hash keys to symbols (by default string keys are used,
15
+ # with fallback to symbol keys)
16
+ #
17
+ # 3. Creates methods on the fly on `OpenStruct` class, instead of singleton class.
18
+ # Uses `module_eval` with string to avoid holding scope references for every method.
19
+ #
20
+ # 4. Refactored, crud clean, spec covered :)
21
+ #
22
+ # @private
23
+ class FasterOpenStruct
24
+ # Undefine particularly nasty interfering methods on Ruby 1.8
25
+ undef :type if method_defined?(:type)
26
+ undef :id if method_defined?(:id)
27
+
28
+ def initialize(hash = nil)
29
+ @hash = hash || {}
30
+ @initialized_empty = hash == nil
31
+ end
32
+
33
+ def method_missing(method_name_sym, *args)
34
+ if method_name_sym.to_s[-1] == ?=
35
+ if args.size != 1
36
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 1)", caller(1)
37
+ end
38
+
39
+ if self.frozen?
40
+ raise TypeError, "can't modify frozen #{self.class}", caller(1)
41
+ end
42
+
43
+ __new_ostruct_member__(method_name_sym.to_s.chomp("="))
44
+ send(method_name_sym, args[0])
45
+ elsif args.size == 0
46
+ __new_ostruct_member__(method_name_sym)
47
+ send(method_name_sym)
48
+ else
49
+ raise NoMethodError, "undefined method `#{method_name_sym}' for #{self}", caller(1)
50
+ end
51
+ end
52
+
53
+ def __new_ostruct_member__(method_name_sym)
54
+ self.class.module_eval <<-END_EVAL, __FILE__, __LINE__ + 1
55
+ def #{ method_name_sym }
56
+ @hash.fetch("#{ method_name_sym }", @hash[:#{ method_name_sym }]) # read by default from string key, then try symbol
57
+ # if string key doesn't exist
58
+ end
59
+ END_EVAL
60
+
61
+ unless method_name_sym.to_s[-1] == ?? # can't define writer for predicate method
62
+ self.class.module_eval <<-END_EVAL, __FILE__, __LINE__ + 1
63
+ def #{ method_name_sym }=(val)
64
+ if @hash.key?("#{ method_name_sym }") || @initialized_empty # write by default to string key (when it is present
65
+ # in initialization hash or initialization hash
66
+ # wasn't provided)
67
+ @hash["#{ method_name_sym }"] = val # if it doesn't exist - write to symbol key
68
+ else
69
+ @hash[:#{ method_name_sym }] = val
70
+ end
71
+ end
72
+ END_EVAL
73
+ end
74
+ end
75
+
76
+ def empty?
77
+ @hash.empty?
78
+ end
79
+
80
+ #
81
+ # Compare this object and +other+ for equality.
82
+ #
83
+ def ==(other)
84
+ return false unless other.is_a?(self.class)
85
+ @hash == other.instance_variable_get(:@hash)
86
+ end
87
+
88
+ #
89
+ # Returns a string containing a detailed summary of the keys and values.
90
+ #
91
+ def inspect
92
+ str = "#<#{ self.class }"
93
+ str << " #{ @hash.map { |k, v| "#{ k }=#{ v.inspect }" }.join(", ") }" unless @hash.empty?
94
+ str << ">"
95
+ end
96
+ alias :to_s :inspect
97
+ end # FasterOpenStruct
98
+ end # Tuber
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tuber
4
+ # Represents a stats hash with proper underscored keys
5
+ class StatStruct < FasterOpenStruct
6
+ # Convert a stats hash into a struct.
7
+ #
8
+ # @param [Hash{String => String}] hash Hash Stats hash to convert to struct
9
+ # @return [Tuber::StatStruct, nil] Stats struct from hash
10
+ # @example
11
+ # s = StatStruct.from_hash(:foo => "bar")
12
+ # s.foo # => 'bar'
13
+ #
14
+ def self.from_hash(hash)
15
+ return unless hash.is_a?(Hash)
16
+ underscore_hash = hash.inject({}) { |r, (k, v)| r[k.to_s.gsub(/-/, '_')] = v; r }
17
+ self.new(underscore_hash)
18
+ end
19
+
20
+ # Access value for stat with specified key.
21
+ #
22
+ # Keys are stored underscored (see {.from_hash}), so hyphenated beanstalkd
23
+ # names like "current-jobs-ready" are underscored before lookup. This also
24
+ # avoids defining an invalid, hyphenated method name via method_missing.
25
+ #
26
+ # @param [String] key Key to fetch from stats.
27
+ # @return [String, Integer] Value for specified stat key.
28
+ # @example
29
+ # @stats['foo'] # => "bar"
30
+ # @stats['current-jobs-ready'] # => 5
31
+ #
32
+ def [](key)
33
+ self.send(key.to_s.gsub(/-/, '_'))
34
+ end
35
+
36
+ # Returns set of keys within this struct
37
+ #
38
+ # @return [Array<String>] Value for specified stat key.
39
+ # @example
40
+ # @stats.keys # => ['foo', 'bar', 'baz']
41
+ #
42
+ def keys
43
+ @hash.keys.map { |k| k.to_s }
44
+ end
45
+
46
+ # Returns the initialization hash
47
+ #
48
+ def to_h
49
+ @hash
50
+ end
51
+ end # StatStruct
52
+ end # Tuber
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tuber/stats/fast_struct'
4
+ require 'tuber/stats/stat_struct'
5
+
6
+ class Tuber
7
+ # Represents stats related to the beanstalkd pool.
8
+ class Stats
9
+
10
+ # @!attribute client
11
+ # @return [Tuber] returns the client instance
12
+ attr_reader :client
13
+
14
+ # Creates new stats instance.
15
+ #
16
+ # @param [Tuber] client The tuber client instance.
17
+ # @example
18
+ # Tuber::Stats.new(@client)
19
+ #
20
+ def initialize(client)
21
+ @client = client
22
+ end
23
+
24
+ # Returns keys for stats data
25
+ #
26
+ # @return [Array<String>] Set of keys for stats.
27
+ # @example
28
+ # @bp.stats.keys # => ["version", "total_connections"]
29
+ #
30
+ # @api public
31
+ def keys
32
+ data.keys
33
+ end
34
+
35
+ # Returns value for specified key.
36
+ #
37
+ # @param [String,Symbol] key Name of key to retrieve
38
+ # @return [String,Integer] Value of specified key
39
+ # @example
40
+ # @bp.stats['total_connections'] # => 4
41
+ #
42
+ def [](key)
43
+ data[key]
44
+ end
45
+
46
+ # Delegates inspection to the real data structure
47
+ #
48
+ # @return [String] returns a string containing a detailed stats summary
49
+ def inspect
50
+ data.to_s
51
+ end
52
+ alias :to_s :inspect
53
+
54
+ # Defines a cached method for looking up data for specified key
55
+ # Protects against infinite loops by checking stacktrace
56
+ # @api public
57
+ def method_missing(name, *args, &block)
58
+ if caller.first !~ /`(method_missing|data')/ && data.keys.include?(name.to_s)
59
+ self.class.class_eval <<-CODE, __FILE__, __LINE__
60
+ def #{name}; data[#{name.inspect}]; end
61
+ CODE
62
+ data[name.to_s]
63
+ else # no key matches or caught infinite loop
64
+ super
65
+ end
66
+ end
67
+
68
+ protected
69
+
70
+ # Returns struct based on stats data from response.
71
+ #
72
+ # @return [Tuber::StatStruct] the stats
73
+ # @example
74
+ # self.data # => { 'version' : 1.7, 'total_connections' : 23 }
75
+ # self.data.total_connections # => 23
76
+ #
77
+ def data
78
+ StatStruct.from_hash(client.connection.transmit('stats')[:body])
79
+ end
80
+ end # Stats
81
+ end # Tuber