Skip to content

Commit

Permalink
Quick debounce initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
tldev committed Apr 26, 2017
0 parents commit 652e254
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/.idea/
/.bundle/
/.yardoc
/Gemfile.lock
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
*.bundle
*.so
*.o
*.a
mkmf.log
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source 'https://rubygems.org'

gemspec
7 changes: 7 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright 2017 Tom Johnell

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'bundler/gem_tasks'
require 'rake/testtask'

Rake::TestTask.new do |t|
t.pattern = 'spec/**/*_spec.rb'
t.libs << 'spec'
end
97 changes: 97 additions & 0 deletions lib/sidekiq/quick_debounce.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
require 'sidekiq/api'

module Sidekiq
class QuickDebounce
CANCEL_EXPIRATION_BUFFER = 60 * 60 * 24

def call(worker, msg, _queue, redis_pool = nil)
@worker = worker.is_a?(String) ? worker.constantize : worker
@msg = msg

return yield unless quick_debounce?

block = Proc.new do |conn|
fetch_current_options(conn)
if current_jid
self.class.cancel!(
conn: conn,
jid: current_jid,
expires_at: current_runs_at + CANCEL_EXPIRATION_BUFFER
)
end
jid = yield
update_options(conn, jid['jid'], @msg['at'])
jid
end

if redis_pool
redis_pool.with(&block)
else
Sidekiq.redis(&block)
end
end

private

def quick_debounce?
(delayed? && quick_debounce_options) || false
end

def quick_debounce_options
@quick_debounce_options ||= @worker.get_sidekiq_options['quick_debounce']
end

def update_options(conn, jid, runs_at)
# Don't debounce next job if current job is running right now
return if runs_at <= Time.now.to_f

conn.setex(
worker_key,
runs_at.to_i - Time.now.to_i,
{ jid: jid, runs_at: runs_at }.to_json
)
end

def fetch_current_options(conn)
@options ||= begin
options = conn.get(worker_key)
options ? JSON.parse(options) : {}
end
end

def current_jid
@options['jid']
end

def current_runs_at
Time.at(@options['runs_at']) if @options['runs_at']
end

def worker_key
@worker_key ||= begin
hash = Digest::MD5.hexdigest(@msg['args'].to_json)
"sidekiq_quick_debounce:#{@worker.name}:#{hash}"
end
end

def delayed?
!!@msg['at']
end

class << self
def cancelled?(jid:)
Sidekiq.redis { |conn| conn.exists(cancel_namespace_key(jid)) }
end

def cancel!(conn:, jid:, expires_at:)
conn.setex(cancel_namespace_key(jid), expires_at.to_i - Time.now.to_i, 1)
end

private

def cancel_namespace_key(key)
"sidekiq_quick_debounce:cancelled:#{key}"
end
end
end
end
5 changes: 5 additions & 0 deletions lib/sidekiq/quick_debounce/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module Sidekiq
class QuickDebounce
VERSION = '0.0.1'
end
end
31 changes: 31 additions & 0 deletions sidekiq-quick-debounce.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# coding: utf-8
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'sidekiq/quick_debounce/version'

Gem::Specification.new do |spec|
spec.name = 'sidekiq-quick-debounce'
spec.version = Sidekiq::QuickDebounce::VERSION
spec.authors = ['Tom Johnell']
spec.email = ['[email protected]']
spec.summary = 'A client-side middleware for quickly debouncing Sidekiq jobs'
spec.description = <<-TXT
TBD
TXT
spec.homepage = 'https://github.com/tldev/sidekiq-quick-debounce'
spec.license = 'MIT'

spec.files = `git ls-files -z`.split("\x0")
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
spec.test_files = spec.files.grep(%r{^spec/})
spec.require_paths = ['lib']

spec.add_dependency 'sidekiq', '>= 2.17'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'bundler', '~> 1.6'
spec.add_development_dependency 'mock_redis'
spec.add_development_dependency 'mocha'
spec.add_development_dependency 'simplecov'
spec.add_development_dependency 'codeclimate-test-reporter', '~> 1.0.0'
spec.add_development_dependency 'minitest'
end
43 changes: 43 additions & 0 deletions spec/sidekiq/quick_debounce_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require 'spec_helper'
require 'sidekiq/quick_debounce'
require 'sidekiq'

class QuickDebouncedWorker
include Sidekiq::Worker

sidekiq_options quick_debounce: true

def perform(_a, _b); end
end

describe Sidekiq::QuickDebounce do
after do
Sidekiq.redis(&:flushdb)
end

let(:set) { Sidekiq::ScheduledSet.new }

it 'queues a job normally at first' do
QuickDebouncedWorker.perform_in(60, 'foo', 'bar')
set.size.must_equal 1, 'set.size must be 1'
end

it 'cancels existing job for repeat jobs within the debounce time' do
jid = QuickDebouncedWorker.perform_in(60, 'foo', 'bar')
QuickDebouncedWorker.perform_in(60, 'foo', 'bar')
Sidekiq::QuickDebounce.cancelled?(jid: jid).must_equal true, 'first jid must be cancelled'
set.size.must_equal 2, 'set.size must be 2'
end

it 'debounces jobs based on their arguments' do
jid = QuickDebouncedWorker.perform_in(60, 'boo', 'far')
QuickDebouncedWorker.perform_in(60, 'foo', 'bar')
Sidekiq::QuickDebounce.cancelled?(jid: jid).must_equal false, 'first jid must not be cancelled'
set.size.must_equal 2, 'set.size must be 2'
end

it 'creates the job immediately when given an instant job' do
QuickDebouncedWorker.perform_async('foo', 'bar')
set.size.must_equal 0, 'set.size must be 0'
end
end
17 changes: 17 additions & 0 deletions spec/sidekiq_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require 'sidekiq'
require 'sidekiq/testing'
require 'mock_redis'

# Disable Sidekiq's testing mocks and use MockRedis instead
Sidekiq::Testing.disable!

Sidekiq.configure_server do |config|
config.redis = ConnectionPool.new(size: 1) { MockRedis.new }
end
Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.add Sidekiq::QuickDebounce
end

config.redis = ConnectionPool.new(size: 1) { MockRedis.new }
end
7 changes: 7 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require 'simplecov'
SimpleCov.start

require 'minitest/spec'
require 'minitest/autorun'
require 'mocha/mini_test'
require 'sidekiq_helper'

0 comments on commit 652e254

Please sign in to comment.