red_blocks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +78 -0
- data/Guardfile +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +57 -0
- data/Rakefile +6 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/lib/red_blocks.rb +44 -0
- data/lib/red_blocks/cache_policy.rb +21 -0
- data/lib/red_blocks/composed_expression.rb +27 -0
- data/lib/red_blocks/composed_set.rb +57 -0
- data/lib/red_blocks/config.rb +30 -0
- data/lib/red_blocks/domain_error.rb +14 -0
- data/lib/red_blocks/enum_set.rb +36 -0
- data/lib/red_blocks/expression.rb +21 -0
- data/lib/red_blocks/instant_set.rb +28 -0
- data/lib/red_blocks/intersection_set.rb +17 -0
- data/lib/red_blocks/operations.rb +53 -0
- data/lib/red_blocks/paginator.rb +39 -0
- data/lib/red_blocks/set.rb +110 -0
- data/lib/red_blocks/set_optimizer.rb +23 -0
- data/lib/red_blocks/set_utils.rb +65 -0
- data/lib/red_blocks/subtraction_set.rb +51 -0
- data/lib/red_blocks/union_set.rb +17 -0
- data/lib/red_blocks/unit_set.rb +11 -0
- data/lib/red_blocks/version.rb +3 -0
- data/red_blocks.gemspec +32 -0
- metadata +186 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 6068b8cc4aca279849069f786e0b5e03fa60e7d8
|
4
|
+
data.tar.gz: 74d53563c0eff20138845520f5b883a8660c1bc4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 549e680a248cc49ad5efb363e92233fb227a5ddb34d156bdb4acc1db98ecf35ae4686f25445ff2c27224d4f7490501b296636b6a89a2eb885984fabeb246028c
|
7
|
+
data.tar.gz: 43857558b8ef435078061f953a16361f0d8e99b8446f48acf2f68db5603a68b1dc1623de8d7e37f576d639237a7dcf96dc0704aba018bde88860163798a9f9ef
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
red_blocks (0.1.0)
|
5
|
+
redis (~> 3.3)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
coderay (1.1.2)
|
11
|
+
diff-lcs (1.3)
|
12
|
+
ffi (1.9.18)
|
13
|
+
formatador (0.2.5)
|
14
|
+
guard (2.14.1)
|
15
|
+
formatador (>= 0.2.4)
|
16
|
+
listen (>= 2.7, < 4.0)
|
17
|
+
lumberjack (~> 1.0)
|
18
|
+
nenv (~> 0.1)
|
19
|
+
notiffany (~> 0.0)
|
20
|
+
pry (>= 0.9.12)
|
21
|
+
shellany (~> 0.0)
|
22
|
+
thor (>= 0.18.1)
|
23
|
+
guard-compat (1.2.1)
|
24
|
+
guard-rspec (4.7.3)
|
25
|
+
guard (~> 2.1)
|
26
|
+
guard-compat (~> 1.1)
|
27
|
+
rspec (>= 2.99.0, < 4.0)
|
28
|
+
listen (3.1.5)
|
29
|
+
rb-fsevent (~> 0.9, >= 0.9.4)
|
30
|
+
rb-inotify (~> 0.9, >= 0.9.7)
|
31
|
+
ruby_dep (~> 1.2)
|
32
|
+
lumberjack (1.0.12)
|
33
|
+
method_source (0.9.0)
|
34
|
+
mock_redis (0.17.3)
|
35
|
+
nenv (0.3.0)
|
36
|
+
notiffany (0.1.1)
|
37
|
+
nenv (~> 0.1)
|
38
|
+
shellany (~> 0.0)
|
39
|
+
pry (0.11.1)
|
40
|
+
coderay (~> 1.1.0)
|
41
|
+
method_source (~> 0.9.0)
|
42
|
+
rake (10.5.0)
|
43
|
+
rb-fsevent (0.10.2)
|
44
|
+
rb-inotify (0.9.10)
|
45
|
+
ffi (>= 0.5.0, < 2)
|
46
|
+
redis (3.3.3)
|
47
|
+
rspec (3.6.0)
|
48
|
+
rspec-core (~> 3.6.0)
|
49
|
+
rspec-expectations (~> 3.6.0)
|
50
|
+
rspec-mocks (~> 3.6.0)
|
51
|
+
rspec-core (3.6.0)
|
52
|
+
rspec-support (~> 3.6.0)
|
53
|
+
rspec-expectations (3.6.0)
|
54
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
55
|
+
rspec-support (~> 3.6.0)
|
56
|
+
rspec-mocks (3.6.0)
|
57
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
58
|
+
rspec-support (~> 3.6.0)
|
59
|
+
rspec-support (3.6.0)
|
60
|
+
ruby_dep (1.5.0)
|
61
|
+
shellany (0.0.1)
|
62
|
+
thor (0.20.0)
|
63
|
+
|
64
|
+
PLATFORMS
|
65
|
+
ruby
|
66
|
+
|
67
|
+
DEPENDENCIES
|
68
|
+
bundler (~> 1.13)
|
69
|
+
guard
|
70
|
+
guard-rspec
|
71
|
+
mock_redis (~> 0.17)
|
72
|
+
pry
|
73
|
+
rake (~> 10.0)
|
74
|
+
red_blocks!
|
75
|
+
rspec (~> 3.0)
|
76
|
+
|
77
|
+
BUNDLED WITH
|
78
|
+
1.13.6
|
data/Guardfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
guard :rspec, cmd: "bundle exec rspec" do
|
2
|
+
require "guard/rspec/dsl"
|
3
|
+
dsl = Guard::RSpec::Dsl.new(self)
|
4
|
+
|
5
|
+
# Feel free to open issues for suggestions and improvements
|
6
|
+
|
7
|
+
# RSpec files
|
8
|
+
rspec = dsl.rspec
|
9
|
+
watch(rspec.spec_helper) { rspec.spec_dir }
|
10
|
+
watch(rspec.spec_support) { rspec.spec_dir }
|
11
|
+
watch(rspec.spec_files)
|
12
|
+
|
13
|
+
# Ruby files
|
14
|
+
ruby = dsl.ruby
|
15
|
+
dsl.watch_spec_files_for(ruby.lib_files)
|
16
|
+
end
|
17
|
+
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Altech
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# RedBlocks
|
2
|
+
|
3
|
+
## What is this?
|
4
|
+
|
5
|
+
This gem provides some set classes based on Redis sorted set.
|
6
|
+
So a set may have scores for each elements.
|
7
|
+
|
8
|
+
By using them, it is possible to implement fast ranking system, search system, or filtering system based on coherent cache management of Redis.
|
9
|
+
|
10
|
+
## Example
|
11
|
+
|
12
|
+
Please suppose that there are some tagged documents and we want to provide search system.
|
13
|
+
A user can full-text search, and filter by the tags.
|
14
|
+
|
15
|
+
For example, to implement the following query:
|
16
|
+
|
17
|
+
- serach by the word of "cache mangement"
|
18
|
+
- And filter by the tag of "new" or "starred"
|
19
|
+
|
20
|
+
We construct the follwoing set:
|
21
|
+
|
22
|
+
```rb
|
23
|
+
keyword_set = KeywordSet.new("Ruby") # Each elements have score based on full-text serach.
|
24
|
+
tagged_set_1 = TaggedSet.new(:new)
|
25
|
+
tagged_set_2 = TaggedSet.new(:starred)
|
26
|
+
|
27
|
+
set = IntersectionSet.new([
|
28
|
+
keyword_set,
|
29
|
+
UnionSet.new([tagged_set_1, tagged_set_2]),
|
30
|
+
], cache_time: 3.minutes)
|
31
|
+
set.ids #=> [3, 4, 8, 9, 1]
|
32
|
+
```
|
33
|
+
|
34
|
+
The result is order by the score of keyword set, because each scores of elements are summed on intersection, or union by default.
|
35
|
+
|
36
|
+
This is a simple example, and it is possible to apply this pattern to construct more complex system which has small latency.
|
37
|
+
For example, if you want to personalize the result, you have to prepare a personalized ranking and use it as a base set.
|
38
|
+
|
39
|
+
In general, there are some advantages by using RedBlocks.
|
40
|
+
|
41
|
+
- aggregate the result of each service by sorted set operations.
|
42
|
+
- flatten the latency of each service(e.g. full-text search service, recommendation service) by Redis cache.
|
43
|
+
- get rid of the cost of Redis key management from programmer.
|
44
|
+
- get rid of manual checking whether the cache exists or not from programmer.
|
45
|
+
- possible to construct such systems in object-oridented style.
|
46
|
+
|
47
|
+
## Installation
|
48
|
+
|
49
|
+
```rb
|
50
|
+
gem 'red_blocks'
|
51
|
+
```
|
52
|
+
|
53
|
+
## Classes
|
54
|
+
|
55
|
+
TODO: document
|
56
|
+
|
57
|
+
The latter half of the [slide](https://speakerdeck.com/altech/redblocks) describes the classes of RedBlocks (in japanese).
|
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/lib/red_blocks.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class << self
|
3
|
+
attr_accessor :config
|
4
|
+
|
5
|
+
def config
|
6
|
+
@config ||= RedBlocks::Config.new(
|
7
|
+
client_proc: -> { RedBlocks.client },
|
8
|
+
key_namespace: 'RB',
|
9
|
+
intermediate_set_lifetime: 30,
|
10
|
+
blank_id: 0,
|
11
|
+
)
|
12
|
+
end
|
13
|
+
|
14
|
+
def client
|
15
|
+
if self.config.cache_client
|
16
|
+
@cln ||= config.client_proc.call
|
17
|
+
else
|
18
|
+
config.client_proc.call
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "red_blocks/config"
|
25
|
+
|
26
|
+
require "red_blocks/cache_policy"
|
27
|
+
require "red_blocks/set_optimizer"
|
28
|
+
require "red_blocks/set_utils"
|
29
|
+
require "red_blocks/paginator"
|
30
|
+
|
31
|
+
require "red_blocks/operations"
|
32
|
+
require "red_blocks/expression"
|
33
|
+
require "red_blocks/composed_expression"
|
34
|
+
|
35
|
+
require "red_blocks/domain_error"
|
36
|
+
|
37
|
+
require "red_blocks/set"
|
38
|
+
require "red_blocks/composed_set"
|
39
|
+
require "red_blocks/union_set"
|
40
|
+
require "red_blocks/intersection_set"
|
41
|
+
require "red_blocks/subtraction_set"
|
42
|
+
require "red_blocks/unit_set"
|
43
|
+
require "red_blocks/enum_set"
|
44
|
+
require "red_blocks/instant_set"
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
module CachePolicy
|
3
|
+
def self.none
|
4
|
+
0
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.daily
|
8
|
+
# 15.minutes is padding for daily update.
|
9
|
+
# This will avoid to expire the cache before
|
10
|
+
# the update has completed.
|
11
|
+
24.hours + 15.minutes
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.hourly
|
15
|
+
# 3.minutes is padding for hourly update.
|
16
|
+
# This will avoid to expire the cache before
|
17
|
+
# the update has completed.
|
18
|
+
1.hour + 3.minutes
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class ComposedExpression < Expression
|
3
|
+
attr_accessor :operands, :operator
|
4
|
+
|
5
|
+
def initialize(key, operator:, operands:, weight: 1, label: nil)
|
6
|
+
super(key, weight: weight, label: label)
|
7
|
+
@operator = operator
|
8
|
+
@operands = operands
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_s
|
12
|
+
operands_str = operands
|
13
|
+
.select { |exp| exp.is_a?(ComposedExpression) || exp.score.nil? || exp.score != 0 }
|
14
|
+
.map { |exp| exp.to_s }
|
15
|
+
case operator
|
16
|
+
when :sum
|
17
|
+
str = operands_str.join(' + ')
|
18
|
+
str = "(#{str}) * #{weight}" if weight != 1
|
19
|
+
str
|
20
|
+
else
|
21
|
+
str = "#{operator.to_s}(#{operands_str.join(', ')})"
|
22
|
+
str = "#{str} * #{weight}" if weight != 1
|
23
|
+
str
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class ComposedSet < RedBlocks::Set
|
3
|
+
attr_accessor :sets, :score_func
|
4
|
+
attr_writer :cache_time
|
5
|
+
|
6
|
+
SCORE_AGGREGATORS = [:sum, :min, :max]
|
7
|
+
|
8
|
+
def initialize(sets, score_func: :sum, cache_time: nil)
|
9
|
+
if sets.size&.zero? # Accepts enumerator, which can return nil for size.
|
10
|
+
raise ArgumentError.new("Passed sets are blank.")
|
11
|
+
end
|
12
|
+
unless SCORE_AGGREGATORS.include?(score_func)
|
13
|
+
raise ArgumentError.new("`#{score_func.inspect}` is not valid aggregator. Avaiable aggregator is #{SCORE_AGGREGATORS.join(', ')}")
|
14
|
+
end
|
15
|
+
unless sets.all? { |set| set.is_a?(RedBlocks::Set) }
|
16
|
+
raise TypeError.new("sets must be a Array<RedBlocks::Set>, but got the following list: #{sets.map(&:class).join(', ')}")
|
17
|
+
end
|
18
|
+
|
19
|
+
@sets = sets
|
20
|
+
@score_func = score_func
|
21
|
+
@cache_time = cache_time
|
22
|
+
end
|
23
|
+
|
24
|
+
def key_suffix
|
25
|
+
joined_key(@sets.map(&:key).sort, sep: '|', wrap: true)
|
26
|
+
end
|
27
|
+
|
28
|
+
def update!
|
29
|
+
disabled_sets.each(&:update!)
|
30
|
+
compose_sets!
|
31
|
+
RedBlocks.client.expire(key, expiration_time)
|
32
|
+
end
|
33
|
+
|
34
|
+
def cache_time
|
35
|
+
@cache_time || super
|
36
|
+
end
|
37
|
+
|
38
|
+
def expression(id)
|
39
|
+
ComposedExpression.new(key, operator: score_func, operands: sets.map {|s| s.expression(id)}, label: label, weight: weight)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def compose_sets!
|
45
|
+
raise NotImplementedError
|
46
|
+
end
|
47
|
+
|
48
|
+
def disabled_sets
|
49
|
+
ttls = RedBlocks.client.pipelined do
|
50
|
+
@sets.each { |set| RedBlocks.client.ttl(set.key) }
|
51
|
+
end
|
52
|
+
@sets.zip(ttls).select do |set, ttl|
|
53
|
+
set.disabled?(ttl)
|
54
|
+
end.map(&:first)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class Config
|
3
|
+
# Proc which returns Redis client.
|
4
|
+
attr_reader :client_proc
|
5
|
+
attr_reader :cache_client
|
6
|
+
|
7
|
+
# The key prefix for Redis.
|
8
|
+
attr_reader :key_namespace
|
9
|
+
|
10
|
+
# It is guaranteed that all set exist in 30 seconds at least.
|
11
|
+
# This means that all transaction must be completed in the time.
|
12
|
+
attr_reader :intermediate_set_lifetime
|
13
|
+
|
14
|
+
# Used to represent a "blank" set.
|
15
|
+
# So you cannot use 0 in real id.
|
16
|
+
attr_reader :blank_id
|
17
|
+
|
18
|
+
# For test. See spec_helper.rb
|
19
|
+
attr_reader :infinity
|
20
|
+
|
21
|
+
def initialize(client_proc:, cache_client: false, key_namespace:, intermediate_set_lifetime:, blank_id:, infinity: Float::INFINITY)
|
22
|
+
@key_namespace = key_namespace
|
23
|
+
@intermediate_set_lifetime = intermediate_set_lifetime
|
24
|
+
@blank_id = blank_id
|
25
|
+
@client_proc = client_proc
|
26
|
+
@cache_client = cache_client
|
27
|
+
@infinity = infinity
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class EnumSet < RedBlocks::Set
|
3
|
+
attr_reader :value
|
4
|
+
|
5
|
+
def initialize(value)
|
6
|
+
unless self.class.available_values.include?(value)
|
7
|
+
raise RedBlocks::OutOfDomainError.new(value, self.class.available_values)
|
8
|
+
end
|
9
|
+
@value = value
|
10
|
+
end
|
11
|
+
|
12
|
+
# Match string such as `#<User:0x007fa3323a2b90>`
|
13
|
+
ADDRESSED_OBJECT_REGEXP = /^#<(.+):0x\w+>$/
|
14
|
+
|
15
|
+
def key_suffix
|
16
|
+
suffix = @value.to_s
|
17
|
+
if suffix =~ ADDRESSED_OBJECT_REGEXP
|
18
|
+
raise <<MSG
|
19
|
+
The key suffix may include random address #{suffix.inspect}.
|
20
|
+
You must override `key_suffix` explicitly, or use `value` which implements deterministic `to_s`.
|
21
|
+
MSG
|
22
|
+
end
|
23
|
+
suffix
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.warmup!
|
27
|
+
available_values.each do |value|
|
28
|
+
self.new(value).update!
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.available_values
|
33
|
+
raise NotImplementedError
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class Expression
|
3
|
+
attr_accessor :score, :weight, :label, :key
|
4
|
+
|
5
|
+
def initialize(key, score: nil, weight: 1, label: nil)
|
6
|
+
@score = score
|
7
|
+
@weight = weight
|
8
|
+
@key = key
|
9
|
+
@label = label
|
10
|
+
end
|
11
|
+
|
12
|
+
def label
|
13
|
+
@label || key
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
socre_str = score ? '%0.4f' % (score * weight) : '?'
|
18
|
+
"#{socre_str}[#{label}]"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class InstantSet < RedBlocks::Set
|
3
|
+
attr_reader :value, :suffix
|
4
|
+
|
5
|
+
RAND_MAX = 100_000_000
|
6
|
+
|
7
|
+
def initialize(value)
|
8
|
+
unless value.is_a?(Array)
|
9
|
+
raise TypeError.new("Expect value as Array, but got #{ids_or_ids_with_scores.class}")
|
10
|
+
end
|
11
|
+
|
12
|
+
@value = value
|
13
|
+
@suffix = rand(RAND_MAX)
|
14
|
+
end
|
15
|
+
|
16
|
+
def cache_time
|
17
|
+
0
|
18
|
+
end
|
19
|
+
|
20
|
+
def get
|
21
|
+
value
|
22
|
+
end
|
23
|
+
|
24
|
+
def key_suffix
|
25
|
+
suffix
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class IntersectionSet < ComposedSet
|
3
|
+
private
|
4
|
+
|
5
|
+
def compose_sets!
|
6
|
+
sets = @sets.to_a
|
7
|
+
if sets.size > 0
|
8
|
+
RedBlocks.client.zinterstore(key, sets.map(&:key), weights: sets.map(&:weight), aggregate: score_func)
|
9
|
+
else
|
10
|
+
RedBlocks.client.pipelined do
|
11
|
+
RedBlocks.client.del(key)
|
12
|
+
RedBlocks.client.zadd(key, normalize_entries([]))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# Operations for development.
|
2
|
+
# Please be careful with using in prduction.
|
3
|
+
module RedBlocks::Operations
|
4
|
+
def self.delete(set_or_pattern)
|
5
|
+
keys = self.keys(set_or_pattern)
|
6
|
+
RedBlocks.client.pipelined do
|
7
|
+
keys.each do |key|
|
8
|
+
RedBlocks.client.del(key)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.keys(set_or_pattern)
|
14
|
+
if set_or_pattern.is_a?(Class) && set_or_pattern <= RedBlocks::Set
|
15
|
+
pattern = set_or_pattern.key_pattern
|
16
|
+
elsif set_or_pattern.is_a?(String)
|
17
|
+
pattern = set_or_pattern
|
18
|
+
else
|
19
|
+
raise ArgumentError.new("Unexpected pattern(#{set_or_pattern.inspect})")
|
20
|
+
end
|
21
|
+
scan_keys(pattern)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.delete_intermediate(set, threshold: RedBlocks::CachePolicy.none)
|
25
|
+
case set
|
26
|
+
when RedBlocks::ComposedSet
|
27
|
+
if set.cache_time <= threshold
|
28
|
+
RedBlocks.client.del(set.key)
|
29
|
+
end
|
30
|
+
|
31
|
+
set.sets.each do |internal_set|
|
32
|
+
delete_intermediate(internal_set, threshold: threshold)
|
33
|
+
end
|
34
|
+
when RedBlocks::Set
|
35
|
+
if set.cache_time <= threshold
|
36
|
+
RedBlocks.client.del(set.key)
|
37
|
+
end
|
38
|
+
else
|
39
|
+
raise TypeError.new("Expect RedBlocks::Set, but got #{set.class.name}")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def self.scan_keys(pattern)
|
46
|
+
result = []
|
47
|
+
while true
|
48
|
+
cursor, keys = RedBlocks.client.scan(cursor, match: pattern, count: 1000)
|
49
|
+
result += keys
|
50
|
+
return result if cursor == "0"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class Paginator
|
3
|
+
DEFAULT_PER = 10
|
4
|
+
DEFAULT_PAGE = 1
|
5
|
+
|
6
|
+
attr_reader :head, :tail, :per, :page
|
7
|
+
|
8
|
+
def initialize(per: DEFAULT_PER, page: DEFAULT_PAGE, head: nil, tail: nil)
|
9
|
+
if head && tail
|
10
|
+
unless head >= 0 && tail >= 0 && head <= tail
|
11
|
+
raise ArgumentError.new("head and tail is out of range (head=#{head}, tail=#{tail})")
|
12
|
+
end
|
13
|
+
|
14
|
+
@head = head
|
15
|
+
@tail = tail
|
16
|
+
return
|
17
|
+
end
|
18
|
+
|
19
|
+
@per = per.to_i
|
20
|
+
@page = page.to_i
|
21
|
+
|
22
|
+
@head = @per * (@page - 1)
|
23
|
+
@tail = @head + @per - 1
|
24
|
+
end
|
25
|
+
|
26
|
+
def size
|
27
|
+
@per || (@tail - @head + 1)
|
28
|
+
end
|
29
|
+
|
30
|
+
class All
|
31
|
+
def head; 0 end
|
32
|
+
def tail; -1 end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.all
|
36
|
+
All.new
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class Set
|
3
|
+
include SetUtils
|
4
|
+
include SetOptimizer
|
5
|
+
|
6
|
+
def update!(get_result = self.get)
|
7
|
+
entries = normalize_entries(validate_entries!(get_result))
|
8
|
+
removed_ids = self.ids(paginator: Paginator.all, update_if_disabled: false) - entries.map(&:last)
|
9
|
+
RedBlocks.client.pipelined do
|
10
|
+
RedBlocks.client.zrem(key, removed_ids) if removed_ids.size > 0
|
11
|
+
RedBlocks.client.zadd(key, entries)
|
12
|
+
RedBlocks.client.expire(key, expiration_time)
|
13
|
+
end
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def update_if_disabled!
|
18
|
+
update! if disabled?
|
19
|
+
end
|
20
|
+
|
21
|
+
def disabled?(ttl = RedBlocks.client.ttl(key))
|
22
|
+
ttl < RedBlocks.config.intermediate_set_lifetime
|
23
|
+
end
|
24
|
+
|
25
|
+
def expiration_time
|
26
|
+
RedBlocks.config.intermediate_set_lifetime + cache_time
|
27
|
+
end
|
28
|
+
|
29
|
+
def ids(paginator: Paginator.new, with_scores: false, with_expressions: false, update_if_disabled: true)
|
30
|
+
update_if_disabled! if update_if_disabled
|
31
|
+
res = RedBlocks.client.zrevrange(key, paginator.head, paginator.tail, with_scores: with_scores)
|
32
|
+
res
|
33
|
+
.map { |id, _score|
|
34
|
+
if with_scores || with_expressions
|
35
|
+
res = [id.to_i]
|
36
|
+
res << _score if with_scores
|
37
|
+
res << expression(id) if with_expressions
|
38
|
+
res
|
39
|
+
else
|
40
|
+
id.to_i
|
41
|
+
end
|
42
|
+
}.select{ |id, _, _|
|
43
|
+
id != RedBlocks.config.blank_id
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def size(update_if_disabled: true)
|
48
|
+
update_if_disabled! if update_if_disabled
|
49
|
+
RedBlocks.client.zcard(key) - 1 # Discount the blank entry.
|
50
|
+
end
|
51
|
+
|
52
|
+
def key
|
53
|
+
joined_key([RedBlocks.config.key_namespace, self.class.key_prefix, key_suffix])
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.key_pattern
|
57
|
+
joined_key([RedBlocks.config.key_namespace, key_prefix, '*'])
|
58
|
+
end
|
59
|
+
|
60
|
+
# # Overridable to sub-classes.
|
61
|
+
# ------------------------------------------------------
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# @return [String] A key prefix. This is intended to distinct sets on class-level.
|
66
|
+
# So it uses class name by default. It uses name without module to save memory.
|
67
|
+
def self.key_prefix
|
68
|
+
self.name
|
69
|
+
end
|
70
|
+
|
71
|
+
# @return [String] A key suffix. This is intended to distinct sets on instance-level.
|
72
|
+
# So it will use some instance variable.
|
73
|
+
def key_suffix
|
74
|
+
raise NotImplementedError
|
75
|
+
end
|
76
|
+
|
77
|
+
public
|
78
|
+
|
79
|
+
# @return [Array, Array<Array>] A list of ids, or a zippped list of ids and scores.
|
80
|
+
# @example
|
81
|
+
# [1, 3, 5]
|
82
|
+
# @example
|
83
|
+
# [[1, 3.4], [5, 2.3]]
|
84
|
+
def get
|
85
|
+
raise NotImplementedError
|
86
|
+
end
|
87
|
+
|
88
|
+
# @return [Integer] Cache time in seconds.
|
89
|
+
def cache_time
|
90
|
+
RedBlocks::CachePolicy.none
|
91
|
+
end
|
92
|
+
|
93
|
+
# @return [Numeric] A scale factor for score calculaton on intersection or union.
|
94
|
+
def weight
|
95
|
+
@weight || 1
|
96
|
+
end
|
97
|
+
|
98
|
+
attr_writer :weight
|
99
|
+
|
100
|
+
def label
|
101
|
+
nil
|
102
|
+
end
|
103
|
+
|
104
|
+
# Used in debug mode (with_expressions option)
|
105
|
+
def expression(id)
|
106
|
+
score = self.disabled? ? nil : (RedBlocks.client.zscore(key, id) || 0)
|
107
|
+
Expression.new(key, score: score, label: label, weight: weight)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
module SetOptimizer
|
3
|
+
# Remove intersection set(and union set) that has one set.
|
4
|
+
# i.e. `IntersectionSet.new([some_set])` is always equal
|
5
|
+
# to `some_set`. So we can remove it.
|
6
|
+
def unset
|
7
|
+
case self
|
8
|
+
when RedBlocks::ComposedSet
|
9
|
+
if self.sets.size == 1
|
10
|
+
self.sets.first
|
11
|
+
else
|
12
|
+
new_set = self.dup
|
13
|
+
new_set.sets = self.sets.map { |set| set.unset }
|
14
|
+
new_set
|
15
|
+
end
|
16
|
+
when RedBlocks::Set
|
17
|
+
self
|
18
|
+
else
|
19
|
+
raise TypeError.new("Expected a set but got #{self}")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
module SetUtils
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
class InvalidEntriesError < StandardError
|
8
|
+
def initialize(entries, key)
|
9
|
+
@entries = entries
|
10
|
+
@key = key
|
11
|
+
end
|
12
|
+
attr_reader :entries, :key
|
13
|
+
|
14
|
+
def message
|
15
|
+
"Invalid entry: `#{entries.inspect}` for key = #{self.key}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
def joined_key(array, sep: ':', wrap: false)
|
21
|
+
array.map { |key| wrap ? "[#{key}]" : key.to_s }.join(sep)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def joined_key(*args); self.class.joined_key(*args) end
|
26
|
+
|
27
|
+
def normalize_entries(entries)
|
28
|
+
blank_entry = [-RedBlocks.config.infinity, RedBlocks.config.blank_id]
|
29
|
+
if entries.first.is_a?(Array)
|
30
|
+
entries.map{ |id, score| [score, id] } + [blank_entry]
|
31
|
+
else
|
32
|
+
entries.map {|id| [0, id] } + [blank_entry]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_entries!(entries)
|
37
|
+
case entries
|
38
|
+
when Array
|
39
|
+
entry = entries[0]
|
40
|
+
case entry
|
41
|
+
when Integer
|
42
|
+
entries
|
43
|
+
when Array
|
44
|
+
if !validate_array_entry(entry)
|
45
|
+
raise InvalidEntriesError.new(entries, key)
|
46
|
+
else
|
47
|
+
entries
|
48
|
+
end
|
49
|
+
when NilClass
|
50
|
+
entries
|
51
|
+
else
|
52
|
+
raise InvalidEntriesError.new(entries, key)
|
53
|
+
end
|
54
|
+
else
|
55
|
+
raise InvalidEntriesError.new(entries, key)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate_array_entry(entry)
|
60
|
+
is_pair = entry.size == 2
|
61
|
+
id, score = entry
|
62
|
+
is_pair && id.is_a?(Integer) && (score.is_a?(Numeric) && !score.to_f.nan?)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class SubtractionSet < RedBlocks::Set
|
3
|
+
attr_accessor :set1, :set2
|
4
|
+
attr_writer :cache_time
|
5
|
+
|
6
|
+
def initialize(set1, set2, cache_time: nil)
|
7
|
+
unless [set1, set2].all? { |set| set.is_a?(RedBlocks::Set) }
|
8
|
+
raise TypeError.new("set1 and set2 must be a Array<RedBlocks::Set>, but got the following list: #{sets.map(&:class).join(', ')}")
|
9
|
+
end
|
10
|
+
|
11
|
+
@set1 = set1
|
12
|
+
@set2 = set2
|
13
|
+
@cache_time = cache_time
|
14
|
+
end
|
15
|
+
|
16
|
+
def key_suffix
|
17
|
+
joined_key(sets.map(&:key).sort, sep: '|', wrap: true)
|
18
|
+
end
|
19
|
+
|
20
|
+
def update!
|
21
|
+
disabled_sets.each(&:update!)
|
22
|
+
# [Note] The following operation will be faster if we use Redis script.
|
23
|
+
RedBlocks.client.zunionstore(key, [set1.key])
|
24
|
+
RedBlocks.client.zrem(key, set2.ids(paginator: RedBlocks::Paginator.all))
|
25
|
+
RedBlocks.client.expire(key, expiration_time)
|
26
|
+
end
|
27
|
+
|
28
|
+
def cache_time
|
29
|
+
@cache_time || super
|
30
|
+
end
|
31
|
+
|
32
|
+
def expression(id)
|
33
|
+
set1.expression(id)
|
34
|
+
end
|
35
|
+
|
36
|
+
def sets
|
37
|
+
[set1, set2]
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def disabled_sets
|
43
|
+
ttls = RedBlocks.client.pipelined do
|
44
|
+
sets.each { |set| RedBlocks.client.ttl(set.key) }
|
45
|
+
end
|
46
|
+
sets.zip(ttls).select do |set, ttl|
|
47
|
+
set.disabled?(ttl)
|
48
|
+
end.map(&:first)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module RedBlocks
|
2
|
+
class UnionSet < ComposedSet
|
3
|
+
private
|
4
|
+
|
5
|
+
def compose_sets!
|
6
|
+
sets = @sets.to_a
|
7
|
+
if sets.size > 0
|
8
|
+
RedBlocks.client.zunionstore(key, sets.map(&:key), weights: sets.map(&:weight), aggregate: score_func)
|
9
|
+
else
|
10
|
+
RedBlocks.client.pipelined do
|
11
|
+
RedBlocks.client.del(key)
|
12
|
+
RedBlocks.client.zadd(key, normalize_entries([]))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/red_blocks.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'red_blocks/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "red_blocks"
|
8
|
+
spec.version = RedBlocks::VERSION
|
9
|
+
spec.authors = ["Altech"]
|
10
|
+
spec.email = ["takeno.sh@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = 'Object-oriented abstraction of Redis sorted set.'
|
13
|
+
spec.description = 'This module provides classes of redis sorted set'\
|
14
|
+
' to implement fast ranking, search, filtering'\
|
15
|
+
' with coherent cache management policy.'
|
16
|
+
spec.homepage = "https://github.com/Altech/red_blocks"
|
17
|
+
spec.license = "MIT"
|
18
|
+
|
19
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
20
|
+
f.match(%r{^(test|spec|features)/})
|
21
|
+
end
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
spec.add_dependency "redis", "~> 3.3"
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.13"
|
26
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
27
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency "mock_redis", "~> 0.17"
|
29
|
+
spec.add_development_dependency "guard"
|
30
|
+
spec.add_development_dependency "guard-rspec"
|
31
|
+
spec.add_development_dependency "pry"
|
32
|
+
end
|
metadata
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: red_blocks
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Altech
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-12-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.13'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.13'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: mock_redis
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.17'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.17'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: guard
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: guard-rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: pry
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: This module provides classes of redis sorted set to implement fast ranking,
|
126
|
+
search, filtering with coherent cache management policy.
|
127
|
+
email:
|
128
|
+
- takeno.sh@gmail.com
|
129
|
+
executables: []
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- CHANGELOG.md
|
134
|
+
- Gemfile
|
135
|
+
- Gemfile.lock
|
136
|
+
- Guardfile
|
137
|
+
- LICENSE.txt
|
138
|
+
- README.md
|
139
|
+
- Rakefile
|
140
|
+
- bin/console
|
141
|
+
- bin/setup
|
142
|
+
- lib/red_blocks.rb
|
143
|
+
- lib/red_blocks/cache_policy.rb
|
144
|
+
- lib/red_blocks/composed_expression.rb
|
145
|
+
- lib/red_blocks/composed_set.rb
|
146
|
+
- lib/red_blocks/config.rb
|
147
|
+
- lib/red_blocks/domain_error.rb
|
148
|
+
- lib/red_blocks/enum_set.rb
|
149
|
+
- lib/red_blocks/expression.rb
|
150
|
+
- lib/red_blocks/instant_set.rb
|
151
|
+
- lib/red_blocks/intersection_set.rb
|
152
|
+
- lib/red_blocks/operations.rb
|
153
|
+
- lib/red_blocks/paginator.rb
|
154
|
+
- lib/red_blocks/set.rb
|
155
|
+
- lib/red_blocks/set_optimizer.rb
|
156
|
+
- lib/red_blocks/set_utils.rb
|
157
|
+
- lib/red_blocks/subtraction_set.rb
|
158
|
+
- lib/red_blocks/union_set.rb
|
159
|
+
- lib/red_blocks/unit_set.rb
|
160
|
+
- lib/red_blocks/version.rb
|
161
|
+
- red_blocks.gemspec
|
162
|
+
homepage: https://github.com/Altech/red_blocks
|
163
|
+
licenses:
|
164
|
+
- MIT
|
165
|
+
metadata: {}
|
166
|
+
post_install_message:
|
167
|
+
rdoc_options: []
|
168
|
+
require_paths:
|
169
|
+
- lib
|
170
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
171
|
+
requirements:
|
172
|
+
- - ">="
|
173
|
+
- !ruby/object:Gem::Version
|
174
|
+
version: '0'
|
175
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
176
|
+
requirements:
|
177
|
+
- - ">="
|
178
|
+
- !ruby/object:Gem::Version
|
179
|
+
version: '0'
|
180
|
+
requirements: []
|
181
|
+
rubyforge_project:
|
182
|
+
rubygems_version: 2.5.1
|
183
|
+
signing_key:
|
184
|
+
specification_version: 4
|
185
|
+
summary: Object-oriented abstraction of Redis sorted set.
|
186
|
+
test_files: []
|