Skip to content

Commit e6e724f

Browse files
committed
Add Money::Distributed::Storage
1 parent ddcbcd7 commit e6e724f

11 files changed

+255
-0
lines changed

.gitignore

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/.bundle/
2+
/.yardoc
3+
/Gemfile.lock
4+
/_yardoc/
5+
/coverage/
6+
/doc/
7+
/pkg/
8+
/spec/reports/
9+
/tmp/

.rspec

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--color

Gemfile

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
source 'https://rubygems.org'
2+
3+
# Specify your gem's dependencies in money-distributed.gemspec
4+
gemspec

lib/money-distributed.rb

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require 'money/distributed/storage'

lib/money/distributed/redis.rb

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
require 'redis'
2+
require 'connection_pool'
3+
4+
class Money
5+
module Distributed
6+
# Wrapper over different parameters that can be provided for redis
7+
class Redis
8+
def initialize(redis)
9+
@redis_proc = build_redis_proc(redis)
10+
end
11+
12+
def exec(&block)
13+
@redis_proc.call(&block)
14+
end
15+
16+
private
17+
18+
# rubocop: disable Metrics/MethodLength
19+
def build_redis_proc(redis)
20+
case redis
21+
when ::Redis
22+
proc { |&b| b.call(redis) }
23+
when ConnectionPool
24+
proc { |&b| redis.with { |r| b.call(r) } }
25+
when Hash
26+
build_redis_proc(::Redis.new(redis))
27+
when Proc
28+
redis
29+
else
30+
raise ArgumentError, 'Redis, ConnectionPool, Hash or Proc is required'
31+
end
32+
end
33+
# rubocop: enable Metrics/MethodLength
34+
end
35+
end
36+
end

lib/money/distributed/storage.rb

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
require 'bigdecimal'
2+
require 'money/distributed/redis'
3+
4+
class Money
5+
module Distributed
6+
# Storage for `Money::Bank::VariableExchange` that stores rates in Redis
7+
class Storage
8+
INDEX_KEY_SEPARATOR = '_TO_'.freeze
9+
REDIS_KEY = 'money_rates'.freeze
10+
11+
def initialize(redis, cache_ttl = nil)
12+
@redis = Money::Distributed::Redis.new(redis)
13+
14+
@cache = {}
15+
@cache_ttl = cache_ttl
16+
@cache_updated_at = nil
17+
18+
@mutex = Mutex.new
19+
end
20+
21+
def add_rate(iso_from, iso_to, rate)
22+
@redis.exec do |r|
23+
r.hset(REDIS_KEY, key_for(iso_from, iso_to), rate)
24+
end
25+
clear_cache
26+
end
27+
28+
def get_rate(iso_from, iso_to)
29+
cached_rates[key_for(iso_from, iso_to)]
30+
end
31+
32+
def each_rate
33+
enum = Enumerator.new do |yielder|
34+
cached_rates.each do |key, rate|
35+
iso_from, iso_to = key.split(INDEX_KEY_SEPARATOR)
36+
yielder.yield iso_from, iso_to, rate
37+
end
38+
end
39+
40+
block_given? ? enum.each(&block) : enum
41+
end
42+
43+
def transaction
44+
# We don't need transactions, we all thread safe here
45+
yield
46+
end
47+
48+
def marshal_dump
49+
[self.class, @cache_ttl]
50+
end
51+
52+
private
53+
54+
def key_for(iso_from, iso_to)
55+
[iso_from, iso_to].join(INDEX_KEY_SEPARATOR).upcase
56+
end
57+
58+
def cached_rates
59+
@mutex.synchronize do
60+
retrieve_rates if @cache.empty? || cache_outdated?
61+
@cache
62+
end
63+
end
64+
65+
def cache_outdated?
66+
return false unless @cache_ttl
67+
@cache_updated_at.nil? ||
68+
@cache_updated_at < Time.now - @cache_ttl
69+
end
70+
71+
def clear_cache
72+
@mutex.synchronize do
73+
@cache.clear
74+
end
75+
end
76+
77+
def retrieve_rates
78+
@redis.exec do |r|
79+
r.hgetall(REDIS_KEY).each_with_object(@cache) do |(key, val), h|
80+
h[key] = BigDecimal.new(val)
81+
end
82+
end
83+
@cache_updated_at = Time.now
84+
end
85+
end
86+
end
87+
end

lib/money/distributed/version.rb

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Money
2+
module Distributed
3+
VERSION = '0.0.1'.freeze
4+
end
5+
end

money-distributed.gemspec

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# coding: utf-8
2+
lib = File.expand_path('../lib', __FILE__)
3+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4+
require 'money/distributed/version'
5+
6+
Gem::Specification.new do |spec|
7+
spec.name = 'money-distributed'
8+
spec.version = Money::Distributed::VERSION
9+
spec.authors = ['DarthSim']
10+
spec.email = ['[email protected]']
11+
12+
spec.summary = 'Money gem extension for distributed systems'
13+
spec.homepage = 'https://github.com/DarthSim/money-distributed'
14+
spec.license = 'MIT'
15+
16+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
17+
f.match(%r{^(test|spec|features)/})
18+
end
19+
20+
spec.bindir = 'exe'
21+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22+
spec.require_paths = ['lib']
23+
24+
spec.add_development_dependency 'bundler', '~> 1.10'
25+
spec.add_development_dependency 'rake', '~> 10.0'
26+
spec.add_development_dependency 'rspec'
27+
spec.add_development_dependency 'timecop'
28+
spec.add_development_dependency 'pry'
29+
spec.add_development_dependency 'rubocop'
30+
31+
spec.add_dependency 'money'
32+
spec.add_dependency 'redis'
33+
spec.add_dependency 'connection_pool'
34+
end

spec/redis_spec.rb

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
require 'spec_helper'
2+
3+
describe Money::Distributed::Redis do
4+
it 'accepts Redis' do
5+
redis = Redis.new
6+
expect(described_class.new(redis).exec(&:ping)).to eq 'PONG'
7+
end
8+
9+
it 'accepts ConnectionPool' do
10+
pool = ConnectionPool.new { Redis.new }
11+
expect(described_class.new(pool).exec(&:ping)).to eq 'PONG'
12+
end
13+
14+
it 'accepts Proc' do
15+
redis = Redis.new
16+
redis_proc = proc { |&b| b.call(redis) }
17+
expect(described_class.new(redis_proc).exec(&:ping)).to eq 'PONG'
18+
end
19+
20+
it 'accepts Hash' do
21+
options = { host: '127.0.0.1', db: 0 }
22+
expect(described_class.new(options).exec(&:ping)).to eq 'PONG'
23+
end
24+
25+
it "doesn't accept other classes" do
26+
redis = double(ping: true)
27+
expect { described_class.new(redis).exec(&:ping) }.to \
28+
raise_error(ArgumentError)
29+
end
30+
end

spec/spec_helper.rb

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
require 'rspec'
2+
require 'timecop'
3+
require 'money-distributed'
4+
5+
RSpec.configure do |c|
6+
c.order = :random
7+
c.filter_run :focus
8+
c.run_all_when_everything_filtered = true
9+
10+
c.after do
11+
Timecop.return
12+
end
13+
end

spec/storage_spec.rb

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
require 'spec_helper'
2+
3+
describe Money::Distributed::Storage do
4+
let(:redis) { Redis.new }
5+
let(:ttl) { 3600 }
6+
7+
subject { described_class.new(redis, ttl) }
8+
9+
it 'stores rates in redis' do
10+
subject.add_rate 'USD', 'RUB', 60.123
11+
expect(redis.hget(described_class::REDIS_KEY, 'USD_TO_RUB')).to eq '60.123'
12+
end
13+
14+
it 'gets rates from redis' do
15+
redis.hset(described_class::REDIS_KEY, 'USD_TO_RUB', 60.123)
16+
expect(subject.get_rate('USD', 'RUB')).to eq 60.123
17+
end
18+
19+
it 'caches rates in memory' do
20+
redis.hset(described_class::REDIS_KEY, 'USD_TO_RUB', 60.123)
21+
subject.get_rate('USD', 'RUB')
22+
redis.hset(described_class::REDIS_KEY, 'USD_TO_RUB', 70.456)
23+
24+
expect(subject.get_rate('USD', 'RUB')).to eq 60.123
25+
end
26+
27+
it 'clears rates cache after ttl' do
28+
redis.hset(described_class::REDIS_KEY, 'USD_TO_RUB', 60.123)
29+
subject.get_rate('USD', 'RUB')
30+
redis.hset(described_class::REDIS_KEY, 'USD_TO_RUB', 70.456)
31+
32+
Timecop.freeze(Time.now + ttl + 1)
33+
expect(subject.get_rate('USD', 'RUB')).to eq 70.456
34+
end
35+
end

0 commit comments

Comments
 (0)