resque-rate_limited 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1d7f4298346cb2c327ba603fd644953c89397d0d
4
+ data.tar.gz: b860c30f7289da40aa619cbb66ec59ea3f6afece
5
+ SHA512:
6
+ metadata.gz: c5ce4337150d6b28bfe5ff6c202d187eaa0e34323b66a9d6789be3c8834b220839f49aa51486d243d21a73c42f11f085270ebe6a25d0628e5f5a1ece1d160da1
7
+ data.tar.gz: a2cb553a6a1856f3cece469a99451b4faa923e0ef5278cc03881b07479f513b196e964cfb7df12bf436a9c2c3b5dd19563c49ff78276cf8e655f7376dfc476bf
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ .ruby-gemset
2
+ .ruby-version
3
+ /.bundle/
4
+ /.yardoc
5
+ /Gemfile.lock
6
+ /_yardoc/
7
+ /coverage/
8
+ /doc/
9
+ /pkg/
10
+ /spec/reports/
11
+ /tmp/
12
+ *.bundle
13
+ *.so
14
+ *.o
15
+ *.a
16
+ mkmf.log
data/.hound.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ ruby:
3
+ enabled: true
4
+ config_file: .rubocop.yml
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/.rubocop.yml ADDED
@@ -0,0 +1,46 @@
1
+ ---
2
+ AllCops:
3
+ Exclude:
4
+ - 'tmp/**/*'
5
+ - 'coverage/**/*'
6
+ - 'spec/dummy/**/*'
7
+
8
+ #-------------------------------------------------------------------------------
9
+ # Project standards
10
+ #-------------------------------------------------------------------------------
11
+ StringLiterals:
12
+ EnforcedStyle: single_quotes
13
+ Enabled: true
14
+
15
+ DotPosition:
16
+ Description: 'Checks the position of the dot in multi-line method calls.'
17
+ EnforcedStyle: leading
18
+ Enabled: true
19
+
20
+ Documentation:
21
+ Description: 'Document classes and non-namespace modules.'
22
+ Enabled: false
23
+
24
+ FileName:
25
+ Description: 'Use snake_case for source file names.'
26
+ Enabled: true
27
+
28
+ Style/SymbolArray:
29
+ Description: 'Use %i or %I for arrays of symbols.'
30
+ StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-i'
31
+ Enabled: false # Only available in Ruby 2.0+
32
+
33
+ Style/ExtraSpacing:
34
+ Description: 'Do not use unnecessary spacing.'
35
+ Enabled: true
36
+
37
+ Lint/LiteralInInterpolation:
38
+ Description: 'Avoid interpolating literals in strings'
39
+ AutoCorrect: true
40
+
41
+ #-------------------------------------------------------------------------------
42
+ # These rules have been relaxed because of existing code
43
+ # We should tighten these up over time
44
+ #-------------------------------------------------------------------------------
45
+ LineLength:
46
+ Max: 166 # project standard is 120
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in resque_rate_limited.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,16 @@
1
+ guard :rubocop do
2
+ watch(/.+\.rb$/)
3
+ watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) }
4
+ end
5
+
6
+ guard(
7
+ :rspec,
8
+ all_after_pass: true,
9
+ all_on_start: true,
10
+ cmd: 'NO_SIMPLECOV=true bundle exec rspec --fail-fast --format documentation'
11
+ ) do
12
+ watch(%r{spec/.+_spec\.rb$})
13
+ watch(%r{lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
14
+ watch('spec/spec_helper.rb') { 'spec' }
15
+ watch(%r{^spec/support/.+\.rb$}) { 'spec' }
16
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 pavoni
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # Resque Rate Limited
2
+
3
+ A Resque plugin which makes handling jobs that use rate limited apis easier
4
+
5
+ If you have a series of jobs in a queue, this gem will pause the queue when one of the jobs hits a rate limit, and re-start the queue when the rate limit has expired.
6
+
7
+ There are two ways to use the gem.
8
+
9
+ If the api you are using has a dedicated queue included in the gem (currently Twitter, Angellist and Evernote) then you just need to make some very minor changes to how you queue jobs, and the gem will do the rest.
10
+
11
+ If you are using another API, then you need to write a little code that catches the rate limit signal.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'resque-rate_limited'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install resque-rate_limited
28
+
29
+ ## Usage
30
+
31
+ ### Configuration
32
+ #### Redis
33
+ The gem uses [redis-mutex](https://github.com/kenn/redis-mutex ) which requires you to register the Redis server: (e.g. in `config/initializers/redis_mutex.rb` for Rails)
34
+
35
+ ```ruby
36
+ RedisClassy.redis = Redis.new
37
+ ```
38
+ Note that Redis Mutex uses the `redis-classy` gem internally.
39
+
40
+ #### Un Pause
41
+ Queues can be unpaused in two ways.
42
+
43
+ The most elegant is using [resque-scheduler](https://github.com/resque/resque-scheduler), this works well as long as you aren't running on a platform like heroku which requires a dedicated worker to run the resque-scheduler.
44
+
45
+ To tell the gem to use `resque-scheduler` you need to include it in your Gemfile - and also let the gem know which queue to use to schedule the unpause job (make sure this isn't a queue that could get paused). Put this in an initializer.
46
+
47
+ ```ruby
48
+ Resque::Plugins::RateLimited::UnPause.queue = :my_queue
49
+ ```
50
+
51
+ Please see the section below on how to unpause on heroku as an alternative. If you don't install `resque-scheduler` AND configure the queue, then the gem will not schedule unpause jobs this way.
52
+
53
+ #### Workers
54
+ Queues are paused by renaming them, so a resque queue called 'twitter\_api' will be renamed 'twitter\_api\_paused' when it hits a rate limit. Of course this will only work if your resque workers are not also taking jobs from the 'twitter\_api\_paused' queue. So your worker commands need to look like:
55
+
56
+ Either
57
+ ```ruby
58
+ bin/resque work --queues=high,low,twitter_api
59
+ ```
60
+ or
61
+ ```ruby
62
+ env QUEUES=high,low,twitter_api bundle exec rake jobs:work
63
+ ```
64
+
65
+ NOT
66
+ ```ruby
67
+ bin/resque work --queues=*
68
+ ```
69
+ or NOT
70
+ ```ruby
71
+ env QUEUES=* bundle exec rake jobs:work
72
+ ```
73
+
74
+ #### Unpausing on heroku
75
+ The built in schedler on heroku doesn't support dynamic scheduling from an API, so unless you want to provision an extra worker to run resque-scheduler - the best option is just to unpause all your queues on a regular basis. If they aren't paused this is a harmless no-op. If not enough time has elapsed the jobs will just hit the rate_limit and get paused again. We've found that a hourly 'rake unpause' job seems to work well. The rake task would need to call:
76
+
77
+ ```ruby
78
+ Resque::Plugins::RateLimited.TwitterQueue.un_pause
79
+ Resque::Plugins::RateLimited.AngellistQueue.un_pause
80
+ MyQueue.un_pause
81
+ MyJob.un_pause
82
+ ```
83
+ ### A pausable job using one of the build-in queues (Twitter, Angellist, Evernote)
84
+ If you're using the [twitter gem](https://github.com/sferik/twitter), this is really simple. Instead of queuing using Resque.enqueue, you just use Resque::Plugins::RateLimited:TwitterQueue.enqueue.
85
+
86
+ Make sure your code in perform doesn't catch the rate_limit exception.
87
+
88
+ The TwitterQueue will catch the exception and pause the queue (as well as re-scheduling the jobs and scheduling an un pause (if you are using resque-scheduler)). Any jobs you add while the queue is paused will be added to the paused queue
89
+
90
+ ```ruby
91
+ class TwitterJob
92
+ class << self
93
+ def refresh(handle)
94
+ Resque::Plugins::RateLimited:TwitterQueue.enqueue(TwitterJob, handle)
95
+ end
96
+
97
+ def perform(handle)
98
+ do_something_with_the_twitter_gem(handle)
99
+ end
100
+ end
101
+ end
102
+ ```
103
+
104
+ ### A single class of pausable job using a new api
105
+ If you only have one class of job you want to queue using the api, then you can use the PauseQueue module directly
106
+
107
+ ```ruby
108
+ class MyApiJob
109
+ extend Resque::Plugins::RateLimited
110
+ @queue = :my_api
111
+ WAIT_TIME = 60*60
112
+
113
+ def self.perform(*params)
114
+ do_api_stuff
115
+ rescue MyApiRateLimit
116
+ pause_until(Time.now + WAIT_TIME, name)
117
+ rate_limited_requeue(self, *params)
118
+ end
119
+
120
+ def self.enqueue(*params)
121
+ rate_limited_enqueue(self, *params)
122
+ end
123
+ end
124
+ ````
125
+
126
+ ### Multiple classes of pausable job using a new api
127
+ If you have more than one class of job you want to queue to the api, then you need to add another Queue class. This isn't hard
128
+
129
+ ```ruby
130
+ class MyApiQueue < Resque::Plugins::RateLimited::BaseApiQueue
131
+ @queue = :my_api
132
+ WAIT_TIME = 60*60
133
+
134
+ def self.perform(klass, *params)
135
+ super
136
+ rescue MyApiRateLimit
137
+ pause_until(Time.now + WAIT_TIME, name)
138
+ rate_limited_requeue(self, klass, *params)
139
+ end
140
+ end
141
+ ````
142
+ If you do this - please contribute - and I'll add to the gem.
143
+
144
+ ## Development Documentation
145
+ All the functions are class methods
146
+
147
+ ```ruby
148
+ rate_limited_enqueue(klass, *params)
149
+ rate_limited_requeue(klass, *params)
150
+ ````
151
+ Queue the job specified to the resque queue specified by `@queue`. `rate_limited_requeue` is intended for use when you need the job to be pushed back to the queue; there are two reasons to split this from `rate_limited_enqueue`. Firstly it makes testing with stubs easier - secondly there is a boundary condition when you need to requeue the last job in the queue.
152
+
153
+ ```ruby
154
+ pause
155
+ ````
156
+ Pauses the queue specified by `@queue`, if it is not already paused.
157
+ In most cases you should call `pause_until` to pause a queue when you hit a rate limit.
158
+
159
+ ```ruby
160
+ un_pause
161
+ ````
162
+ Un-pauses the queue specified by `@queue`, if it is paused.
163
+
164
+ ```ruby
165
+ pause_until(timestamp)
166
+ ````
167
+ Pauses the queue (specified by `@queue`) and then queues a job to unpause the queue specified by `@queue`, using resque-scheduler to the queue specified by `Resque::Plugins::RateLimited::UnPause.queue` at the timestamp specified.
168
+ If `resque-schedule` is not included, or `UnPause.queue` isn't specified this will just pause the queue.
169
+
170
+ This is the prefered function to call when you hit a rate limit, since it with work regardless of the unpause method used by the application.
171
+
172
+ ```ruby
173
+ paused?
174
+ ````
175
+ This returns true or false to indicate wheher the queue is paused. Be aware that the queue state could change get after the call returns, but before your code executes. Use `with_lock` if you need to avoid this.
176
+
177
+ ```ruby
178
+ paused_queue_name
179
+ ````
180
+ Returns the name of the queue when it is paused.
181
+
182
+ ```ruby
183
+ with_lock(&block)
184
+ ````
185
+ Takes ownership of the PauseQueue semaphor before executing the block passed. Useful if you need to test the state of the queue and take some action without the state changing.
186
+
187
+ ```ruby
188
+ find_class(klass)
189
+ ````
190
+ Takes the parameter passed, and if it's a string class name, tries to turn it into a class.
191
+
192
+
193
+ ## Contributing
194
+
195
+ 1. Fork it ( https://github.com/[my-github-username]/resque_rate_limited/fork )
196
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
197
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
198
+ 4. Push to the branch (`git push origin my-new-feature`)
199
+ 5. Create a new Pull Request
200
+
201
+
202
+ ## Version history
203
+
204
+ 0.0.x Mostly pre-release versions
205
+
206
+ 1.0.0 First release version. Breaking change - renamed `pause_for` to be `pause_until` to better reflect function
207
+
208
+ 1.1.0 Enqueues underlying job instead of directly performing it
209
+
210
+
211
+
212
+ ## Final thoughts
213
+ Thanks to [Dominic](https://github.com/dominicsayers) for idea of renaming the redis key - and the sample code that does this.
214
+
215
+ This is my first gem - so please forgive any errors - and feedback very welcome
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,3 @@
1
+ module RateLimited
2
+ VERSION = '1.1.0'.freeze
3
+ end
@@ -0,0 +1,19 @@
1
+ require 'angellist_api'
2
+
3
+ module Resque
4
+ module Plugins
5
+ module RateLimited
6
+ class AngellistQueue < BaseApiQueue
7
+ WAIT_TIME = 60
8
+ @queue = :angellist_api
9
+
10
+ def self.perform(klass, *params)
11
+ super
12
+ rescue AngellistApi::Error::TooManyRequests
13
+ pause_until(Time.now + (60 * 60))
14
+ rate_limited_requeue(self, klass, *params)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module Resque
2
+ module Plugins
3
+ module RateLimited
4
+ class BaseApiQueue
5
+ extend Resque::Plugins::RateLimited
6
+ def self.perform(klass, *params)
7
+ # find_class(klass).perform(*params)
8
+ Resque.enqueue_to @queue, klass, *params # Enqueue so that ResqueSolo can take effect
9
+ end
10
+
11
+ def self.enqueue(klass, *params)
12
+ rate_limited_enqueue(self, klass.to_s, *params)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ require 'evernote-thrift'
2
+
3
+ module Resque
4
+ module Plugins
5
+ module RateLimited
6
+ class EvernoteQueue < BaseApiQueue
7
+ @queue = :evernote_api
8
+
9
+ def self.perform(klass, *params)
10
+ super
11
+ rescue Evernote::EDAM::Error::EDAMSystemException => e
12
+ raise unless e.errorCode == Evernote::EDAM::Error::EDAMErrorCode::RATE_LIMIT_REACHED
13
+ pause_until(Time.now + 60 * e.rateLimitDuration.seconds)
14
+ rate_limited_requeue(self, klass, *params)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ require 'twitter'
2
+
3
+ module Resque
4
+ module Plugins
5
+ module RateLimited
6
+ class TwitterQueue < BaseApiQueue
7
+ @queue = :twitter_api
8
+
9
+ def self.perform(klass, *params)
10
+ super
11
+ rescue Twitter::Error::TooManyRequests,
12
+ Twitter::Error::EnhanceYourCalm => e
13
+ pause_until(Time.now + e.rate_limit.reset_in)
14
+ rate_limited_requeue(self, klass, *params)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,92 @@
1
+ module Resque
2
+ module Plugins
3
+ module RateLimited
4
+ RESQUE_PREFIX = 'queue:'.freeze
5
+ MUTEX = 'Resque::Plugins::RateLimited'.freeze
6
+
7
+ def around_perform_with_check_and_requeue(*params)
8
+ paused = false
9
+ with_lock do
10
+ paused = paused?
11
+ Resque.enqueue_to(paused_queue_name, self, *params) if paused
12
+ end
13
+ return if paused
14
+ yield
15
+ end
16
+
17
+ def rate_limited_enqueue(klass, *params)
18
+ with_lock do
19
+ if paused?
20
+ Resque.enqueue_to(paused_queue_name, klass, *params)
21
+ else
22
+ Resque.enqueue_to(@queue, klass, *params)
23
+ end
24
+ end
25
+ end
26
+
27
+ def rate_limited_requeue(klass, *params)
28
+ # if the queue is empty, this was the last job - so queue to the paused queue
29
+ with_lock do
30
+ if paused?(true)
31
+ Resque.enqueue_to(paused_queue_name, klass, *params)
32
+ else
33
+ Resque.enqueue_to(@queue, klass, *params)
34
+ end
35
+ end
36
+ end
37
+
38
+ def pause_until(timestamp)
39
+ UnPause.enqueue(timestamp, name) if pause
40
+ end
41
+
42
+ def un_pause
43
+ Resque.redis.renamenx(RESQUE_PREFIX + paused_queue_name, RESQUE_PREFIX + @queue.to_s)
44
+ true
45
+ rescue Redis::CommandError => e
46
+ raise unless e.message == 'ERR no such key'
47
+ false
48
+ end
49
+
50
+ def pause
51
+ Resque.redis.renamenx(RESQUE_PREFIX + @queue.to_s, RESQUE_PREFIX + paused_queue_name)
52
+ true
53
+ rescue Redis::CommandError => e
54
+ raise unless e.message == 'ERR no such key'
55
+ false
56
+ end
57
+
58
+ def paused?(unknown = false)
59
+ # parameter is what to return if the queue is empty, and so the state is unknown
60
+ if Resque.inline
61
+ false
62
+ elsif Resque.redis.exists(RESQUE_PREFIX + @queue.to_s)
63
+ false
64
+ elsif Resque.redis.exists(RESQUE_PREFIX + paused_queue_name)
65
+ true
66
+ else
67
+ unknown
68
+ end
69
+ end
70
+
71
+ def paused_queue_name
72
+ @queue.to_s + '_paused'
73
+ end
74
+
75
+ def with_lock
76
+ if Resque.inline
77
+ yield
78
+ else
79
+ RedisMutex.with_lock(MUTEX, block: 60, expire: 120) { yield }
80
+ end
81
+ end
82
+
83
+ def find_class(klass)
84
+ return klass if klass.is_a? Class
85
+ return Object.const_get(klass) unless klass.include?('::')
86
+ klass.split('::').reduce(Object) do |mod, class_name|
87
+ mod.const_get(class_name)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end