Skip to content

Instantly share code, notes, and snippets.

@leehambley
Last active October 1, 2015 04:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save leehambley/c014dfcc24f80f803621 to your computer and use it in GitHub Desktop.
Save leehambley/c014dfcc24f80f803621 to your computer and use it in GitHub Desktop.
Redis backed SlugGenerator for friendly_id 4, read more at: http://lee.hambley.name/2012/2/27/friendly_id-and-parallel-processes/
# Copyright (C) 2012 Lee Hambley <lee.hambley@gmail.com>
#
# 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.
require 'timeout'
module FriendlyId
class RedisSlugGenerator < SlugGenerator
include Timeout
def next_in_sequence
guarantee_seed_value_present!
redis.incr redis_key
end
private
def guarantee_seed_value_present!
if get_lock
begin
timeout(lock_timeout_in_seconds) do
redis.setnx redis_key, (last_in_sequence == 0 ? 1 : last_in_sequence)
end
ensure
release_lock
end
else
wait_for_lock_to_be_released!
end
end
def get_lock
redis.multi do
redis.setnx redis_lock_key, "Lock acquired at #{Time.now.utc}"
redis.expire_at redis_lock_key, Time.now.utc + lock_timeout_in_seconds
end
end
def release_lock
redis.del redis_lock_key
end
def lock_timeout_in_seconds
5
end
def wait_for_lock_to_be_released
sleep 0.1 while redis.exists redis_lock_key
end
def redis_lock_key
redis_key + ":_lock"
end
def redis_sequence_key
redis_key + ":#{@sluggable.class.name.underscore}"
end
def redis_key
@_redis_key ||= "friendly_id:slug-sequence:#{normalized}"
end
def redis
Rails.application.redis
end
end
end
#
# If you were just here for the headline, you don't need *anything*
# below this line, it's all tests, and stub implementations of the
# dependencies so that this test runs fast. To use these tests
# start a redis server with the default settings. To make sure the
# tests don't delete important data, make sure you don't have any-
# -hing in the Redis DB #10.
#
require 'minitest/autorun'
require 'redis'
#
# This is stolen from ActiveSupport::Inflector
# to enable us to test this without dependencies
# on ActiveSupport
#
class String
def underscore(camel_cased_word)
word = camel_cased_word.to_s.dup
word.gsub!(/::/, '/')
word.gsub!(/(?:([A-Za-z\d])|^)(#{inflections.acronym_regex})(?=\b|[^a-z])/) { "#{$1}#{$1 && '_'}#{$2.downcase}" }
word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
word.tr!("-", "_")
word.downcase!
word
end
end
#
# Stub implementation of the interface required by
# the FriendlyId implementation, this conforms
# to the version 4 API.
#
module FriendlyId
class SlugGenerator
def initialize(sluggable, normalized)
@sluggable, @normalized = sluggable, normalized
end
end
class TestRedisSlugGenerator < RedisSlugGenerator
attr_reader :normalized
def next
"#{normalized}--#{next_in_sequence}"
end
private
def last_in_sequence
0
end
def redis
@redis ||= Redis.new(db: 10)
end
end
end
FakeModelClass = Class.new
class MiniTest::Unit::TestCase
def mock_sluggable
# Only used by the implementation to call
# <sluggable>.class.name.underscore and to_s
FakeModelClass.new
end
private
def flush_redis!
redis.flushall
end
def redis
@redis ||= Redis.new(db: 10)
end
end
module FriendlyId
class TestSimpleSlugGeneration < MiniTest::Unit::TestCase
def test_by_default_the_next_in_sequence_is_2_when_it_is_the_first_uniq_key
flush_redis!
assert_equal 'anything--2', TestRedisSlugGenerator.new(mock_sluggable, "anything").next
end
def test_by_default_the_next_in_sequence_ascends_when_it_is_used_in_sequence
flush_redis!
assert_equal 'anything--2', TestRedisSlugGenerator.new(mock_sluggable, "anything").next
assert_equal 'anything--3', TestRedisSlugGenerator.new(mock_sluggable, "anything").next
end
def test_by_default_the_next_in_sequence_ascends_when_it_is_used_in_parallel_with_threads
flush_redis!
results = [
Thread.new { Thread.current["result"] = TestRedisSlugGenerator.new(mock_sluggable, "anything").next },
Thread.new { Thread.current["result"] = TestRedisSlugGenerator.new(mock_sluggable, "anything").next },
].collect do |thread|
thread.join
thread["result"]
end
refute_equal *results
end
def test_by_default_the_next_in_sequence_ascends_when_it_is_used_in_parallel_with_many_threads
flush_redis!
n = 100
threads = (1..n).collect do |n|
t = Thread.new { Thread.current["result"] = TestRedisSlugGenerator.new(mock_sluggable, "anything").next }
end
threads.each(&:join)
results = threads.collect { |t| t["result"] }
assert_equal n, results.uniq.length
end
def test_by_default_the_next_in_sequence_ascends_per_normalized_slug_when_it_is_used_in_sequence
flush_redis!
assert_equal 'anything--2', TestRedisSlugGenerator.new(mock_sluggable, "anything").next
assert_equal 'something--2', TestRedisSlugGenerator.new(mock_sluggable, "something").next
assert_equal 'anything--3', TestRedisSlugGenerator.new(mock_sluggable, "anything").next
assert_equal 'something--3', TestRedisSlugGenerator.new(mock_sluggable, "something").next
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment