commit 443f78f5459e778d650258353747ffdb621d1452 Author: Andrew Coleman Date: Mon Dec 22 16:03:20 2014 -0600 initial commit of the uncomplicated mutex diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..011d9d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +.bundle diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..ec8ab1e --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'redis', '~> 3.0' +gem 'minitest', '~> 5.0' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..99fb711 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,12 @@ +GEM + remote: https://rubygems.org/ + specs: + minitest (5.5.0) + redis (3.2.0) + +PLATFORMS + ruby + +DEPENDENCIES + minitest (~> 5.0) + redis (~> 3.0) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..aa69c71 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require 'rake/testtask' + +Rake::TestTask.new do |t| + t.libs << 'test' +end + +desc "Run tests" +task :default => :test diff --git a/lib/uncomplicated_mutex.rb b/lib/uncomplicated_mutex.rb new file mode 100644 index 0000000..1cfe471 --- /dev/null +++ b/lib/uncomplicated_mutex.rb @@ -0,0 +1,72 @@ +require 'digest/md5' +require 'redis' + +class UncomplicatedMutex + attr_reader :lock_name + + MutexTimeout = Class.new(StandardError) + + LUA_ACQUIRE = "return redis.call('SET', KEYS[1], ARGV[2], 'NX', 'EX', ARGV[1]) and redis.call('expire', KEYS[1], ARGV[1]) and 1 or 0" + LUA_RELEASE = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" + + def initialize(obj, opts = {}) + @verbose = opts[:verbose] + @timeout = opts[:timeout] || 300 + @fail_on_timeout = opts[:fail_on_timeout] + @ticks = opts[:ticks] || 100 + @wait_tick = @timeout.to_f / @ticks.to_f + @redis = opts[:redis] || Redis.new + @lock_name = "lock:#{obj.class.name}:#{obj.id}".squeeze(":") + @token = Digest::MD5.new.hexdigest("#{@lock_name}_#{Time.now.to_f}") + end + + def acquire_mutex + puts("Running transaction to acquire the lock #{@lock_name}") if @verbose + @redis.eval(LUA_ACQUIRE, [ @lock_name ], [ @timeout, @token ]) == 1 + end + + def destroy_mutex + puts("Destroying the lock #{@lock_name}") if @verbose + @redis.del(@lock_name) + end + + def lock(&block) + begin + wait_for_mutex + yield block + ensure + release_mutex + end + end + + def overwrite_mutex + puts("Replacing the lock #{@lock_name} with #{@token}") if @verbose + @redis.set(@lock_name, @token) + end + + def recurse_until_ready(depth = 1) + return false if depth == @ticks + wait_a_tick if depth > 1 + acquire_mutex || recurse_until_ready(depth + 1) + end + + def release_mutex + puts("Releasing the lock #{@lock_name} if it still holds the value '#{@token}'") if @verbose + @redis.eval(LUA_RELEASE, [ @lock_name ], [ @token ]) + end + + def wait_a_tick + puts("Sleeping #{@wait_tick} for the lock #{@lock_name} to become available") if @verbose + sleep(@wait_tick) + end + + def wait_for_mutex + if recurse_until_ready + puts("Acquired lock #{@lock_name}") if @verbose + else + puts("Failed to acquire the lock") if @verbose + raise MutexTimeout.new("Failed to acquire the lock") if @fail_on_timeout + overwrite_mutex + end + end +end diff --git a/test/test_uncomplicated_mutex.rb b/test/test_uncomplicated_mutex.rb new file mode 100644 index 0000000..a444197 --- /dev/null +++ b/test/test_uncomplicated_mutex.rb @@ -0,0 +1,79 @@ +require 'redis' +require 'uncomplicated_mutex' +require 'minitest' +require 'minitest/autorun' + +class TestUncomplicatedMutex < Minitest::Test + class SlowObject + attr_accessor :id + def initialize + @id = Time.now.to_i + end + end + + def setup + @obj = SlowObject.new + @redis = Redis.new + default_opts = { timeout: 1, ticks: 10, redis: @redis } + @mutex1 = UncomplicatedMutex.new(@obj, default_opts) + @mutex2 = UncomplicatedMutex.new(@obj, default_opts) + @lock_name = @mutex1.lock_name + end + + def test_mutex_works + @mutex1.lock do + assert_equal @redis.exists(@lock_name), true + end + assert_equal @redis.exists(@lock_name), false + end + + def test_sequential_access + @redis.set('lock:testvalue', 1) + @mutex1.lock do + @redis.set('lock:testvalue', 2) + end + @mutex2.lock do + assert_equal(@redis.get('lock:testvalue'), '2') + @redis.del('lock:testvalue') + end + assert_equal @redis.exists('lock:testvalue'), false + end + + def test_exception_is_thrown + begin + @redis.set(@lock_name, 1) + UncomplicatedMutex.new(@obj, { timeout: 1, fail_on_timeout: true, ticks: 10 }).lock do + sleep 2 + end + rescue UncomplicatedMutex::MutexTimeout + pass "Exception thrown" + else + flunk "Exception was not thrown" + ensure + @redis.del(@lock_name) + end + end + + def test_exception_is_not_thrown + begin + @redis.set(@lock_name, 1) + @mutex2.lock do + sleep 1.05 + end + rescue UncomplicatedMutex::MutexTimeout + flunk "Exception thrown" + else + pass "Exception was not thrown" + ensure + @redis.del(@lock_name) + end + end + + def test_lock_is_not_overwritten + @mutex1.lock do + @redis.set(@lock_name, 'abc123') + end + assert_equal(@redis.get(@lock_name), 'abc123') + @redis.del(@lock_name) + end +end diff --git a/uncomplicated_mutex.gemspec b/uncomplicated_mutex.gemspec new file mode 100644 index 0000000..2ccda33 --- /dev/null +++ b/uncomplicated_mutex.gemspec @@ -0,0 +1,15 @@ +Gem::Specification.new do |s| + s.name = 'uncomplicated-mutex' + s.version = '1.0.0' + s.date = '2014-12-22' + s.summary = 'Redis. Lua. Mutex.' + s.description = 'A mutex that uses Redis that is also not complicated.' + s.authors = [ 'Andrew Coleman' ] + s.email = 'penguincoder@gmail.com' + s.files = [ 'lib/uncomplicated_mutex.rb' ] + s.homepage = 'https://github.com/penguincoder/uncomplicated_mutex' + s.license = 'MIT' + + s.add_runtime_dependency 'redis', '~> 3.0' + s.add_development_dependency 'minitest', '~> 5.0' +end