red_blocks 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,5 @@
1
+ ## CHANGELOG
2
+
3
+ ### v0.1.0 / 2017-12-05
4
+
5
+ First release.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in red_blocks.gemspec
4
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "red_blocks"
5
+
6
+ require "pry"
7
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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,14 @@
1
+ module RedBlocks
2
+ class DomainError < StandardError
3
+ def initialize(value, domain)
4
+ @value = value
5
+ @domain = domain
6
+ end
7
+
8
+ def message
9
+ "Input `#{@value.inspect}` is out of the domain `#{@domain.inspect}`"
10
+ end
11
+ end
12
+ end
13
+
14
+
@@ -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
@@ -0,0 +1,11 @@
1
+ module RedBlocks
2
+ class UnitSet < RedBlocks::Set
3
+ def key_suffix
4
+ 'Unit'
5
+ end
6
+
7
+ def self.warmup!
8
+ self.new.update!
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module RedBlocks
2
+ VERSION = "0.1.0"
3
+ end
@@ -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: []