├── .hound.yml ├── Gemfile ├── lib └── sidekiq │ ├── debounce │ └── version.rb │ └── debounce.rb ├── gemfiles ├── Gemfile.sidekiq-2.17.7 ├── Gemfile.sidekiq-3.1.0 ├── Gemfile.sidekiq-3.3.4 └── Gemfile.sidekiq-4.0.2 ├── Rakefile ├── spec ├── spec_helper.rb ├── sidekiq_helper.rb └── sidekiq │ └── debounce_spec.rb ├── .gitignore ├── .editorconfig ├── .travis.yml ├── LICENSE.txt ├── sidekiq-debounce.gemspec ├── README.md └── .rubocop.yml /.hound.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | config_file: .rubocop.yml 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/sidekiq/debounce/version.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | class Debounce 3 | VERSION = '1.1.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.sidekiq-2.17.7: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'sidekiq', '2.17.7' 4 | 5 | gemspec path: '../' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.sidekiq-3.1.0: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem 'sidekiq', '3.1.0' 4 | 5 | gemspec path: '../' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.sidekiq-3.3.4: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem 'sidekiq', '3.3.4' 4 | 5 | gemspec path: '../' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.sidekiq-4.0.2: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'sidekiq', '4.0.2' 4 | 5 | gemspec path: '../' 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |t| 5 | t.pattern = 'spec/**/*_spec.rb' 6 | t.libs << 'spec' 7 | end 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | require 'minitest/spec' 5 | require 'minitest/autorun' 6 | require 'mocha/mini_test' 7 | require 'sidekiq_helper' 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig file 2 | # http://editorconfig.org/ 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.5 4 | - 2.3.1 5 | - jruby-9.0.1.0 6 | - jruby-9.0.5.0 7 | gemfile: 8 | - gemfiles/Gemfile.sidekiq-2.17.7 9 | - gemfiles/Gemfile.sidekiq-3.1.0 10 | - gemfiles/Gemfile.sidekiq-3.3.4 11 | - gemfiles/Gemfile.sidekiq-4.0.2 12 | script: bundle exec rake test 13 | addons: 14 | code_climate: 15 | repo_token: b11e6d8ed83dd2e01424b088c469e2cef525a89e887414f81e6ee7f36b937a1d 16 | after_success: 17 | - bundle exec codeclimate-test-reporter 18 | -------------------------------------------------------------------------------- /spec/sidekiq_helper.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq' 2 | require 'sidekiq/testing' 3 | require 'mock_redis' 4 | 5 | # Disable Sidekiq's testing mocks and use MockRedis instead 6 | Sidekiq::Testing.disable! 7 | 8 | Sidekiq.configure_server do |config| 9 | config.redis = ConnectionPool.new(size: 1) { MockRedis.new } 10 | end 11 | Sidekiq.configure_client do |config| 12 | config.client_middleware do |chain| 13 | chain.add Sidekiq::Debounce 14 | end 15 | 16 | config.redis = ConnectionPool.new(size: 1) { MockRedis.new } 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Peter Lejeck 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. 23 | -------------------------------------------------------------------------------- /spec/sidekiq/debounce_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sidekiq/debounce' 3 | require 'sidekiq' 4 | 5 | class DebouncedWorker 6 | include Sidekiq::Worker 7 | 8 | sidekiq_options debounce: true 9 | 10 | def perform(_a, _b); end 11 | end 12 | 13 | describe Sidekiq::Debounce do 14 | before do 15 | stub_scheduled_set 16 | end 17 | 18 | after do 19 | Sidekiq.redis(&:flushdb) 20 | end 21 | 22 | let(:set) { Sidekiq::ScheduledSet.new } 23 | let(:sorted_entry) { Sidekiq::SortedEntry.new(set, 0, {jid: '54321'}.to_json) } 24 | 25 | it 'queues a job normally at first' do 26 | DebouncedWorker.perform_in(60, 'foo', 'bar') 27 | set.size.must_equal 1, 'set.size must be 1' 28 | end 29 | 30 | it 'ignores repeat jobs within the debounce time and reschedules' do 31 | sorted_entry.expects(:reschedule) 32 | 33 | DebouncedWorker.perform_in(60, 'foo', 'bar') 34 | DebouncedWorker.perform_in(60, 'foo', 'bar') 35 | set.size.must_equal 1, 'set.size must be 1' 36 | end 37 | 38 | it 'debounces jobs based on their arguments' do 39 | DebouncedWorker.perform_in(60, 'boo', 'far') 40 | DebouncedWorker.perform_in(60, 'foo', 'bar') 41 | set.size.must_equal 2, 'set.size must be 2' 42 | end 43 | 44 | it 'creates the job immediately when given an instant job' do 45 | DebouncedWorker.perform_async('foo', 'bar') 46 | set.size.must_equal 0, 'set.size must be 0' 47 | end 48 | 49 | def stub_scheduled_set 50 | set.stubs(:find_job).returns(sorted_entry) 51 | Sidekiq::Debounce.any_instance.stubs(:scheduled_set).returns(set) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /sidekiq-debounce.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'sidekiq/debounce/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'sidekiq-debounce' 8 | spec.version = Sidekiq::Debounce::VERSION 9 | spec.authors = ['Peter Lejeck'] 10 | spec.email = ['me@plejeck.com'] 11 | spec.summary = 'A client-side middleware for debouncing Sidekiq jobs' 12 | spec.description = <<-DESC 13 | Sidekiq::Debounce provides a way to rate-limit creation of Sidekiq jobs. When 14 | you create a job on a Worker with debounce enabled, Sidekiq::Debounce will 15 | delay the job until the debounce period has elapsed with no additional debounce 16 | calls. If you make another job with the same arguments before the specified 17 | time has elapsed, the timer is reset and the entire period must pass again 18 | before the job is executed. 19 | DESC 20 | spec.homepage = 'https://github.com/hummingbird-me/sidekiq-debounce' 21 | spec.license = 'MIT' 22 | 23 | spec.files = `git ls-files -z`.split("\x0") 24 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 25 | spec.test_files = spec.files.grep(%r{^spec/}) 26 | spec.require_paths = ['lib'] 27 | 28 | spec.add_dependency 'sidekiq', '>= 2.17' 29 | spec.add_development_dependency 'rake', '~> 10.0' 30 | spec.add_development_dependency 'bundler', '~> 1.6' 31 | spec.add_development_dependency 'mock_redis' 32 | spec.add_development_dependency 'mocha' 33 | spec.add_development_dependency 'simplecov' 34 | spec.add_development_dependency 'codeclimate-test-reporter', '~> 1.0.0' 35 | spec.add_development_dependency 'minitest' 36 | end 37 | -------------------------------------------------------------------------------- /lib/sidekiq/debounce.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/debounce/version' 2 | require 'sidekiq/api' 3 | 4 | module Sidekiq 5 | class Debounce 6 | def call(worker, msg, _queue, redis_pool = nil) 7 | @worker = worker.is_a?(String) ? worker.constantize : worker 8 | @msg = msg 9 | 10 | return yield unless debounce? 11 | 12 | block = Proc.new do |conn| 13 | # Get JID of the already-scheduled job, if there is one 14 | scheduled_jid = conn.get(debounce_key) 15 | 16 | # Reschedule the old job to when this new job is scheduled for 17 | # Or yield if there isn't one scheduled yet 18 | jid = scheduled_jid ? reschedule(scheduled_jid, @msg['at']) : yield 19 | 20 | store_expiry(conn, jid, @msg['at']) 21 | return false if scheduled_jid 22 | jid 23 | end 24 | 25 | if redis_pool 26 | redis_pool.with(&block) 27 | else 28 | Sidekiq.redis(&block) 29 | end 30 | end 31 | 32 | private 33 | 34 | def store_expiry(conn, job, time) 35 | jid = job.respond_to?(:has_key?) && job.key?('jid') ? job['jid'] : job 36 | conn.set(debounce_key, jid) 37 | conn.expireat(debounce_key, time.to_i) 38 | end 39 | 40 | def debounce_key 41 | hash = Digest::MD5.hexdigest(@msg['args'].to_json) 42 | @debounce_key ||= "sidekiq_debounce:#{@worker.name}:#{hash}" 43 | end 44 | 45 | def scheduled_set 46 | @scheduled_set ||= Sidekiq::ScheduledSet.new 47 | end 48 | 49 | def reschedule(jid, at) 50 | job = scheduled_set.find_job(jid) 51 | job.reschedule(at) unless job.nil? 52 | jid 53 | end 54 | 55 | def debounce? 56 | (@msg['at'] && @worker.get_sidekiq_options['debounce']) || false 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Sidekiq::Debounce 2 | ================= 3 | [![Travis CI](http://img.shields.io/travis/hummingbird-me/sidekiq-debounce/master.svg)](https://travis-ci.org/hummingbird-me/sidekiq-debounce) 4 | [![CodeClimate](http://img.shields.io/codeclimate/github/hummingbird-me/sidekiq-debounce.svg)](https://codeclimate.com/github/NuckChorris/sidekiq-debounce) 5 | [![Coverage](http://img.shields.io/codeclimate/coverage/github/hummingbird-me/sidekiq-debounce.svg)](https://codeclimate.com/github/NuckChorris/sidekiq-debounce) 6 | [![RubyGems](http://img.shields.io/gem/v/sidekiq-debounce.svg)](https://rubygems.org/gems/sidekiq-debounce) 7 | 8 | Sidekiq::Debounce is a client-side Sidekiq middleware which provides a way to 9 | easily rate-limit creation of Sidekiq jobs. 10 | 11 | When you create a job via `#perform_in` on a Worker with debounce enabled, 12 | Sidekiq::Debounce will prevent other jobs with the same arguments from being 13 | created until the time has passed. Every time you create another job with those 14 | same arguments prior to the job being run, the timer is reset and the entire 15 | period must pass again before the job is executed. 16 | 17 | ## Installation 18 | 19 | Add this line to your application's Gemfile: 20 | 21 | ```ruby 22 | gem 'sidekiq-debounce' 23 | ``` 24 | 25 | And then execute: 26 | 27 | $ bundle 28 | 29 | Or install it yourself as: 30 | 31 | $ gem install sidekiq-debounce 32 | 33 | ## Usage 34 | 35 | Add `Sidekiq::Debounce` to your client middleware chain, and then add 36 | `sidekiq_options debounce: true` to the worker you wish to debounce. 37 | 38 | Use `#perform_in` instead of `#perform_async` to set the timeframe. 39 | 40 | ## Contributing 41 | 42 | 1. Fork it ( https://github.com/hummingbird-me/sidekiq-debounce/fork ) 43 | 2. Create your feature branch (`git checkout -b my-new-feature`) 44 | 3. Commit your changes (`git commit -am 'Add some feature'`) 45 | 4. Push to the branch (`git push origin my-new-feature`) 46 | 5. Create a new Pull Request 47 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | RunRailsCops: false 3 | DisplayCopNames: false 4 | DisplayStyleGuide: true 5 | StyleGuideCopsOnly: false 6 | Style/BlockDelimiters: 7 | EnforcedStyle: semantic 8 | Style/CollectionMethods: 9 | PreferredMethods: 10 | collect: map 11 | collect!: map! 12 | inject: reduce 13 | detect: find 14 | find_all: select 15 | Style/CommentAnnotation: 16 | Enabled: true 17 | Keywords: 18 | - TODO 19 | - FIXME 20 | - OPTIMIZE 21 | - HACK 22 | - REVIEW 23 | - DEPRECATED 24 | Style/DotPosition: 25 | EnforcedStyle: leading 26 | Style/FileName: 27 | Enabled: true 28 | Style/FormatString: 29 | Enabled: true 30 | EnforcedStyle: format 31 | Style/GlobalVars: 32 | Enabled: true 33 | Style/GuardClause: 34 | Enabled: true 35 | MinBodyLength: 3 36 | Style/IfUnlessModifier: 37 | Enabled: true 38 | MaxLineLength: 80 39 | Style/LambdaCall: 40 | Enabled: true 41 | Style/Next: 42 | Enabled: true 43 | MinBodyLength: 3 44 | Style/NumericLiterals: 45 | Enabled: true 46 | MinDigits: 5 47 | Style/PercentLiteralDelimiters: 48 | Enabled: true 49 | PreferredDelimiters: 50 | "%": "()" 51 | "%i": "()" 52 | "%q": "()" 53 | "%Q": "()" 54 | "%r": "{}" 55 | "%s": "()" 56 | "%w": "()" 57 | "%W": "()" 58 | "%x": "()" 59 | Style/PredicateName: 60 | Enabled: true 61 | NamePrefix: 62 | - is_ 63 | - has_ 64 | - have_ 65 | NamePrefixBlacklist: 66 | - is_ 67 | Style/RaiseArgs: 68 | Enabled: true 69 | EnforcedStyle: exploded 70 | Style/SingleLineMethods: 71 | Enabled: true 72 | AllowIfMethodIsEmpty: true 73 | Style/StringLiterals: 74 | EnforcedStyle: single_quotes 75 | Style/SpaceAroundEqualsInParameterDefault: 76 | Enabled: true 77 | EnforcedStyle: space 78 | Style/SpaceBeforeBlockBraces: 79 | Enabled: true 80 | EnforcedStyle: space 81 | Style/SpaceInsideBlockBraces: 82 | Enabled: true 83 | EnforcedStyle: space 84 | EnforcedStyleForEmptyBraces: no_space 85 | SpaceBeforeBlockParameters: true 86 | Style/SpaceInsideHashLiteralBraces: 87 | Enabled: true 88 | EnforcedStyle: space 89 | EnforcedStyleForEmptyBraces: no_space 90 | Style/SymbolProc: 91 | Enabled: true 92 | Style/TrailingBlankLines: 93 | Enabled: true 94 | EnforcedStyle: final_newline 95 | Style/TrailingComma: 96 | Enabled: true 97 | EnforcedStyleForMultiline: comma 98 | Style/TrivialAccessors: 99 | Enabled: true 100 | AllowDSLWriters: true 101 | Style/VariableName: 102 | Enabled: true 103 | EnforcedStyle: snake_case 104 | Style/WordArray: 105 | Enabled: true 106 | MinSize: 2 107 | Metrics/AbcSize: 108 | Enabled: true 109 | Max: 25 110 | Metrics/BlockNesting: 111 | Enabled: true 112 | Max: 5 113 | Metrics/ClassLength: 114 | Enabled: true 115 | CountComments: false 116 | Max: 500 117 | Metrics/CyclomaticComplexity: 118 | Enabled: true 119 | Max: 10 120 | Metrics/LineLength: 121 | Enabled: true 122 | Max: 80 123 | AllowURI: true 124 | Metrics/MethodLength: 125 | Enabled: true 126 | CountComments: false 127 | Max: 20 128 | Metrics/ParameterLists: 129 | Enabled: true 130 | Max: 5 131 | CountKeywordArgs: false 132 | Metrics/PerceivedComplexity: 133 | Enabled: true 134 | Max: 8 135 | Lint/AssignmentInCondition: 136 | Enabled: true 137 | AllowSafeAssignment: true 138 | Style/InlineComment: 139 | Enabled: false 140 | Style/MethodCalledOnDoEndBlock: 141 | Enabled: true 142 | Style/SymbolArray: 143 | Enabled: true 144 | Style/AccessorMethodName: 145 | Enabled: true 146 | Style/Alias: 147 | Enabled: true 148 | Style/ArrayJoin: 149 | Enabled: true 150 | Style/AsciiComments: 151 | Enabled: true 152 | Style/AsciiIdentifiers: 153 | Enabled: true 154 | Style/Attr: 155 | Enabled: true 156 | Style/BlockComments: 157 | Enabled: true 158 | Style/ColonMethodCall: 159 | Enabled: true 160 | Style/DeprecatedHashMethods: 161 | Enabled: true 162 | Style/Documentation: 163 | Enabled: false 164 | Style/EmptyLiteral: 165 | Enabled: true 166 | Style/EvenOdd: 167 | Enabled: true 168 | Style/FlipFlop: 169 | Enabled: true 170 | Style/IfWithSemicolon: 171 | Enabled: true 172 | Style/Lambda: 173 | Enabled: true 174 | Style/LeadingCommentSpace: 175 | Enabled: true 176 | Style/MultilineBlockChain: 177 | Enabled: true 178 | Style/MultilineTernaryOperator: 179 | Enabled: true 180 | Style/NegatedIf: 181 | Enabled: true 182 | Style/NestedTernaryOperator: 183 | Enabled: true 184 | Style/NilComparison: 185 | Enabled: true 186 | Style/Not: 187 | Enabled: true 188 | Style/OneLineConditional: 189 | Enabled: true 190 | Style/PerlBackrefs: 191 | Enabled: true 192 | Style/RedundantBegin: 193 | Enabled: true 194 | Style/RedundantSelf: 195 | Enabled: true 196 | Style/RescueModifier: 197 | Enabled: true 198 | Style/SelfAssignment: 199 | Enabled: true 200 | Style/SingleSpaceBeforeFirstArg: 201 | Enabled: true 202 | Style/SpaceAfterColon: 203 | Enabled: true 204 | Style/SpaceAfterComma: 205 | Enabled: true 206 | Style/SpaceAfterControlKeyword: 207 | Enabled: true 208 | Style/SpaceAfterMethodName: 209 | Enabled: true 210 | Style/SpaceAfterNot: 211 | Enabled: true 212 | Style/SpaceAfterSemicolon: 213 | Enabled: true 214 | Style/SpaceBeforeComma: 215 | Enabled: true 216 | Style/SpaceBeforeComment: 217 | Enabled: true 218 | Style/SpaceBeforeSemicolon: 219 | Enabled: true 220 | Style/SpaceAroundOperators: 221 | Enabled: true 222 | Style/SpaceBeforeModifierKeyword: 223 | Enabled: true 224 | Style/SpaceInsideBrackets: 225 | Enabled: true 226 | Style/SpaceInsideParens: 227 | Enabled: true 228 | Style/SpaceInsideRangeLiteral: 229 | Enabled: true 230 | Style/SpecialGlobalVars: 231 | Enabled: true 232 | Style/TrailingWhitespace: 233 | Enabled: true 234 | Style/UnlessElse: 235 | Enabled: true 236 | Style/VariableInterpolation: 237 | Enabled: true 238 | Style/WhenThen: 239 | Enabled: true 240 | Lint/Debugger: 241 | Enabled: true 242 | Lint/DeprecatedClassMethods: 243 | Enabled: true 244 | Lint/Eval: 245 | Enabled: true 246 | Lint/HandleExceptions: 247 | Enabled: true 248 | Lint/LiteralInInterpolation: 249 | Enabled: true 250 | Lint/Loop: 251 | Enabled: true 252 | Lint/ParenthesesAsGroupedExpression: 253 | Enabled: true 254 | Lint/RequireParentheses: 255 | Enabled: false 256 | Lint/UnderscorePrefixedVariableName: 257 | Enabled: true 258 | Lint/UselessSetterCall: 259 | Enabled: true 260 | --------------------------------------------------------------------------------