Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hybrid hash/redis storage #75

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ rvm:
- 2.0.0
- 2.1.2
- 2.2.5
- 2.3.1
- 2.3.0
gemfile:
- gemfiles/rails_4.gemfile
- gemfiles/rails_4.1.gemfile
Expand All @@ -14,6 +14,7 @@ services:
env:
- LIT_STORAGE=hash
- LIT_STORAGE=redis
- LIT_STORAGE=hybrid
before_script:
- cp test/dummy/config/database.yml.travis test/dummy/config/database.yml
- psql -c 'create database lit_test;' -U postgres
Expand Down
3 changes: 3 additions & 0 deletions lib/lit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def self.get_key_value_engine
when 'redis'
require 'lit/adapters/redis_storage'
return RedisStorage.new
when 'hybrid'
require 'lit/adapters/hybrid_storage'
return HybridStorage.new
else
require 'lit/adapters/hash_storage'
return HashStorage.new
Expand Down
180 changes: 180 additions & 0 deletions lib/lit/adapters/hybrid_storage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
require 'redis'
require 'concurrent'

ActionController::Base.class_eval do
after_action :clear_saved_redis_snapshot

def clear_saved_redis_snapshot
Lit.saved_redis_snapshot = nil
end
end

module Lit
extend self
def redis
$redis = Redis.new(url: determine_redis_provider) unless $redis
$redis
end

def determine_redis_provider
ENV[ENV['REDIS_PROVIDER'] || 'REDIS_URL']
end

def _hash
$_hash ||= ::Concurrent::Hash.new
end

def reset_hash
$_hash = nil
end

def hash_dirty?
# Hash is considered dirty if hash snapshot is older
# than Redis snapshot.
Lit.hash_snapshot < Lit.redis_snapshot
end

def hash_snapshot
$_hash_snapshot ||= DateTime.new.to_f.to_d
end

def hash_snapshot= (timestamp)
$_hash_snapshot = timestamp
end

def redis_snapshot
return Lit.saved_redis_snapshot unless Lit.saved_redis_snapshot.nil?
timestamp = Lit.redis.get(Lit.prefix + '_snapshot')
if timestamp.nil?
timestamp = DateTime.now.to_f.to_s
Lit.redis_snapshot = timestamp
end
Lit.saved_redis_snapshot = timestamp.to_f
end

def redis_snapshot= (timestamp)
Lit.redis.set(Lit.prefix + '_snapshot', timestamp)
end

def saved_redis_snapshot
$saved_redis_snapshot ||= Concurrent::MVar.new(nil)
$saved_redis_snapshot.value
end

def saved_redis_snapshot= (snap)
$saved_redis_snapshot ||= Concurrent::MVar.new(nil)
$saved_redis_snapshot.set!(snap)
end

def now_timestamp
DateTime.now.to_f.to_d
end

def determine_redis_provider
ENV[ENV['REDIS_PROVIDER'] || 'REDIS_URL']
end

def prefix
pfx = 'lit:'
if Lit.storage_options.is_a?(Hash)
pfx += "#{Lit.storage_options[:prefix]}:" if Lit.storage_options.key?(:prefix)
end
pfx
end

class HybridStorage
def initialize
Lit.redis
Lit._hash
end

def [](key)
if Lit.hash_dirty?
Lit.hash_snapshot = Lit.now_timestamp
Lit._hash.clear
end
if Lit._hash.key? key
return Lit._hash[key]
else
redis_val = get_from_redis(key)
Lit._hash[key] = redis_val
end
end

def get_from_redis(key)
if Lit.redis.exists(_prefixed_key_for_array(key))
Lit.redis.lrange(_prefixed_key(key), 0, -1)
elsif Lit.redis.exists(_prefixed_key_for_nil(key))
nil
else
Lit.redis.get(_prefixed_key(key))
end
end

def []=(k, v)
delete(k)
Lit._hash[k] = v
if v.is_a?(Array)
Lit.redis.set(_prefixed_key_for_array(k), '1')
v.each do |ve|
Lit.redis.rpush(_prefixed_key(k), ve.to_s)
end
elsif v.nil?
Lit.redis.set(_prefixed_key_for_nil(k), '1')
Lit.redis.set(_prefixed_key(k), '')
else
Lit.redis.set(_prefixed_key(k), v)
end
end

def delete(k)
Lit.redis_snapshot = Lit.now_timestamp
Lit._hash.delete(k)
Lit.redis.del(_prefixed_key_for_array(k))
Lit.redis.del(_prefixed_key_for_nil(k))
Lit.redis.del(_prefixed_key(k))
end

def clear
Lit.redis_snapshot = Lit.now_timestamp
Lit._hash.clear
Lit.redis.del(keys) if keys.length > 0
end

def keys
Lit.redis.keys(_prefixed_key + '*')
end

def has_key?(key)
Lit._hash.has_key?(key) || Lit.redis.exists(_prefixed_key(key)) # This is a derp
end

def incr(key)
Lit.redis.incr(_prefixed_key(key))
end

def sort
Lit.redis.keys.sort.map do |k|
[k, self.[](k)]
end
end

private

def _prefix
Lit.prefix
end

def _prefixed_key(key = '')
_prefix + key.to_s
end

def _prefixed_key_for_array(key = '')
_prefix + 'array_flags:' + key.to_s
end

def _prefixed_key_for_nil(key = '')
_prefix + 'nil_flags:' + key.to_s
end
end
end
15 changes: 9 additions & 6 deletions lib/lit/i18n_backend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,9 @@ def lookup(locale, key, scope = [], options = {})

def store_item(locale, data, scope = [], unless_changed = false)
if data.respond_to?(:to_hash)
# ActiveRecord::Base.transaction do
data.to_hash.each do |key, value|
store_item(locale, value, scope + [key], unless_changed)
end
# end
data.to_hash.each do |key, value|
store_item(locale, value, scope + [key], unless_changed)
end
elsif data.respond_to?(:to_str)
key = ([locale] + scope).join('.')
@cache.update_locale(key, data, false, unless_changed)
Expand Down Expand Up @@ -141,12 +139,17 @@ def is_ignored_key(key_without_locale)
end

def should_cache?(key_with_locale)
return false if @cache.has_key?(key_with_locale)
return false if @cache[key_with_locale] != nil

_, key_without_locale = ::Lit::Cache.split_key(key_with_locale)
return false if is_ignored_key(key_without_locale)

true
end

def extract_non_symbol_default!(options)
defaults = [options[:default]].flatten
defaults.detect{|default| !default.is_a?(Symbol)}
end
end
end
1 change: 1 addition & 0 deletions lit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Gem::Specification.new do |s|
s.add_dependency 'rails', '> 3.1.0'
s.add_dependency 'i18n', '~> 0.7.0'
s.add_dependency 'jquery-rails'
s.add_dependency 'concurrent-ruby'

s.add_development_dependency 'pg'
s.add_development_dependency 'devise'
Expand Down
4 changes: 2 additions & 2 deletions test/dummy/config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ development:
test:
adapter: postgresql
database: lit_test
username: lit
password: lit
username: ebin
password: ebin
host: localhost
pool: 5
timeout: 5000
Expand Down
6 changes: 6 additions & 0 deletions test/support/clear_snapshots.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require 'lit/adapters/hybrid_storage'
def clear_snapshots
Lit.reset_hash if defined?($_hash)
Lit.hash_snapshot = nil if defined?($_hash_snapshot)
Lit.redis.del(Lit.prefix + '_snapshot') if defined?($redis)
end
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def load_sample_yml(fname)
class ActiveSupport::TestCase
self.use_transactional_fixtures = true
setup do
clear_snapshots
clear_redis
Lit.init.cache.reset
end
Expand Down
88 changes: 88 additions & 0 deletions test/unit/hybrid_storage_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
require 'test_helper'

# Applicable only for LIT_STORAGE=hybrid
class HybridStorageTest < ActiveSupport::TestCase
if ENV['LIT_STORAGE'] == 'hybrid'
class Backend < Lit::I18nBackend
end

fixtures :all

def setup
Lit.init
Lit::Localization.delete_all
Lit::LocalizationKey.delete_all
Lit::LocalizationVersion.delete_all
@old_humanize_key = Lit.humanize_key
Lit.humanize_key = false
@old_load_path = I18n.load_path
Lit.reset_hash
I18n.backend.cache.clear
@locale = Lit::Locale.find_by_locale(I18n.locale)
super
end

def teardown
Lit.loader = @old_loader
Lit.humanize_key = @old_humanize_key
I18n.backend = @old_backend
I18n.load_path = @old_load_path
super
end

test 'it should update translation both in hash and in redis' do
# assertions to ensure that storage has been properly cleared
assert_nil Lit._hash['en.fizz']
assert_nil Lit.redis.get(Lit.prefix + 'en.fizz')
I18n.t('fizz', default: 'buzz')
assert_equal 'buzz', Lit._hash['en.fizz']
assert_equal 'buzz', Lit.redis.get(Lit.prefix + 'en.fizz')
end

test 'it should clear hash when loading from redis something not yet in hash' do
# let's do something that creates a hash snapshot timestamp
assert_nil Lit._hash['en.fizz']
old_hash_snapshot = Lit.hash_snapshot
I18n.t('fizz', default: 'buzz')
assert_operator Lit.hash_snapshot, :>, old_hash_snapshot

# in the meantime let's create some new translation
# simulate as if it were created and redis snapshot has been updated
lk = Lit::LocalizationKey.create(localization_key: 'abcd')
l = lk.localizations.create!(locale: @locale, default_value: 'efgh')

Lit.redis.set(Lit.prefix + 'en.abcd', 'efgh')
Lit.saved_redis_snapshot = Lit.now_timestamp
Lit.redis_snapshot = Lit.saved_redis_snapshot
# TODO: consider if this is not too implementation-specific

# assert that the newly created localization has been fetched into hash
assert_equal 'efgh', I18n.t('abcd')
assert_equal 'efgh', Lit._hash['en.abcd']
assert_equal 'efgh', Lit.redis.get(Lit.prefix + 'en.abcd')

# assert that hash cache has been cleared
assert_nil Lit._hash['en.fizz']
I18n.t('fizz')

# assert that the value then gets loaded into hash again
assert_equal 'buzz', Lit._hash['en.fizz']
end

test 'local cache is used even when redis is cleared' do
# define a translation by specifying default value
assert_nil Lit._hash['en.fizz']
I18n.t('fizz', default: 'buzz')
assert_equal 'buzz', Lit._hash['en.fizz']

# clear redis
I18n.backend.cache.clear

# modify local cache and then see if it's used for loading translation
Lit._hash['en.fizz'] = 'fizzbuzz'
assert_equal 'fizzbuzz', I18n.t('fizz')
end
else
puts 'Skipping hybrid storage test'
end
end