boffin 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +8 -0
- data/LICENSE +18 -0
- data/README.md +302 -0
- data/Rakefile +14 -0
- data/boffin.gemspec +29 -0
- data/lib/boffin.rb +87 -0
- data/lib/boffin/config.rb +91 -0
- data/lib/boffin/hit.rb +83 -0
- data/lib/boffin/keyspace.rb +147 -0
- data/lib/boffin/trackable.rb +66 -0
- data/lib/boffin/tracker.rb +229 -0
- data/lib/boffin/utils.rb +171 -0
- data/lib/boffin/version.rb +4 -0
- data/spec/boffin/config_spec.rb +65 -0
- data/spec/boffin/hit_spec.rb +42 -0
- data/spec/boffin/keyspace_spec.rb +85 -0
- data/spec/boffin/trackable_spec.rb +38 -0
- data/spec/boffin/tracker_spec.rb +162 -0
- data/spec/boffin/utils_spec.rb +158 -0
- data/spec/boffin_spec.rb +44 -0
- data/spec/spec_helper.rb +50 -0
- metadata +128 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
module Boffin
|
2
|
+
# Stores configuration state to be used in various parts of the app. You will
|
3
|
+
# likely not need to instantiate Config directly.
|
4
|
+
class Config
|
5
|
+
|
6
|
+
attr_writer \
|
7
|
+
:redis,
|
8
|
+
:namespace,
|
9
|
+
:hours_window_secs,
|
10
|
+
:days_window_secs,
|
11
|
+
:months_window_secs,
|
12
|
+
:cache_expire_secs
|
13
|
+
|
14
|
+
# @param [Hash] opts
|
15
|
+
# The parameters to create a new Config instance
|
16
|
+
# @option opts [Redis] :redis
|
17
|
+
# @option opts [String] :namespace
|
18
|
+
# @option opts [Fixnum] :hours_window_secs
|
19
|
+
# @option opts [Fixnum] :days_window_secs
|
20
|
+
# @option opts [Fixnum] :months_window_secs
|
21
|
+
# @option opts [Fixnum] :cache_expire_secs
|
22
|
+
# @yield [self]
|
23
|
+
def initialize(opts = {}, &block)
|
24
|
+
yield(self) if block_given?
|
25
|
+
update(opts)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Updates self with the values provided
|
29
|
+
# @param [Hash] updates
|
30
|
+
# A hash of options to update the config instance with
|
31
|
+
# @return [self]
|
32
|
+
def update(updates = {})
|
33
|
+
tap do |conf|
|
34
|
+
updates.each_pair { |k, v| conf.send(:"#{k}=", v) }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Creates a copy of self and updates the copy with the values provided
|
39
|
+
# @param [Hash] updates
|
40
|
+
# A hash of options to merge with the instance
|
41
|
+
# @return [Config] the new Config instance with updated values
|
42
|
+
def merge(updates = {})
|
43
|
+
dup.update(updates)
|
44
|
+
end
|
45
|
+
|
46
|
+
# The Redis instance that will be used to store hit data
|
47
|
+
# @return [Redis] the active Redis connection
|
48
|
+
def redis
|
49
|
+
@redis ||= Redis.connect
|
50
|
+
end
|
51
|
+
|
52
|
+
# The namespace to prefix all Redis keys with. Defaults to `"boffin"` or
|
53
|
+
# `"boffin:<env>"` if `RACK_ENV` or `RAILS_ENV` are present in the
|
54
|
+
# environment.
|
55
|
+
# @return [String]
|
56
|
+
def namespace
|
57
|
+
@namespace ||= begin
|
58
|
+
if (env = ENV['RACK_ENV'] || ENV['RAILS_ENV'])
|
59
|
+
"boffin:#{env}"
|
60
|
+
else
|
61
|
+
"boffin"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# @return [Fixnum]
|
67
|
+
# Number of seconds to maintain the hourly hit interval window
|
68
|
+
def hours_window_secs
|
69
|
+
@hours_window_secs ||= 3 * 24 * 3600 # 3 days
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [Fixnum]
|
73
|
+
# Number of seconds to maintain the daily hit interval window
|
74
|
+
def days_window_secs
|
75
|
+
@days_window_secs ||= 3 * 30 * 24 * 3600 # 3 months
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [Fixnum]
|
79
|
+
# Number of seconds to maintain the monthly hit interval window
|
80
|
+
def months_window_secs
|
81
|
+
@months_window_secs ||= 3 * 12 * 30 * 24 * 3600 # 3 years
|
82
|
+
end
|
83
|
+
|
84
|
+
# @return [Fixnum]
|
85
|
+
# Number of seconds to cache the results of `Tracker.top`
|
86
|
+
def cache_expire_secs
|
87
|
+
@cache_expire_secs ||= 15 * 60 # 15 minutes
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
end
|
data/lib/boffin/hit.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
module Boffin
|
2
|
+
# Represents a Hit instance, immutable once created. Interacting with and
|
3
|
+
# instantiating Hit directly is not necessary.
|
4
|
+
class Hit
|
5
|
+
|
6
|
+
# Creates a new Hit instance
|
7
|
+
#
|
8
|
+
# @param [Tracker] tracker
|
9
|
+
# Tracker that is issuing the hit
|
10
|
+
# @param [Symbol] type
|
11
|
+
# Hit type identifier
|
12
|
+
# @param [Object] instance
|
13
|
+
# The instance that is being hit, any object that responds to
|
14
|
+
# `#to_member`, `#id`, or `#to_s`
|
15
|
+
# @param [Array] uniquenesses
|
16
|
+
# An array of which the first object is used to generate a session
|
17
|
+
# identifier for hit uniqueness
|
18
|
+
def initialize(tracker, type, instance, uniquenesses = [])
|
19
|
+
@now = Time.now
|
20
|
+
@sessid = Utils.uniquenesses_as_session_identifier(uniquenesses)
|
21
|
+
@type = type
|
22
|
+
@tracker = tracker
|
23
|
+
@instance = instance
|
24
|
+
@member = Utils.object_as_member(@instance)
|
25
|
+
store
|
26
|
+
freeze
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# @return [Redis]
|
32
|
+
def redis
|
33
|
+
@tracker.redis
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [Keyspace]
|
37
|
+
def keyspace(*args)
|
38
|
+
@tracker.keyspace(*args)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Stores the hit data in each time window interval key for the current time.
|
42
|
+
# If the hit is unique, also add the data to the keys in the unique keyspace.
|
43
|
+
def store
|
44
|
+
if track_hit
|
45
|
+
set_windows(true)
|
46
|
+
else
|
47
|
+
set_windows(false)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Increments the {Keyspace#hit_count} key and adds the session member to
|
52
|
+
# {Keyspace#hits}.
|
53
|
+
# @return [true, false]
|
54
|
+
# `true` if this hit is unique, `false` if it has been made before by the
|
55
|
+
# same session identifer.
|
56
|
+
def track_hit
|
57
|
+
redis.incr(keyspace.hit_count(@type, @instance))
|
58
|
+
redis.zincrby(keyspace.hits(@type, @instance), 1, @sessid) == '1'
|
59
|
+
end
|
60
|
+
|
61
|
+
# Store the hit member across all time interval for the current window
|
62
|
+
# @param [true, false] uniq
|
63
|
+
# If `true` the hit is also added to the keys scoped for unique hits
|
64
|
+
def set_windows(uniq)
|
65
|
+
INTERVAL_TYPES.each do |interval|
|
66
|
+
set_window_interval(interval, true) if uniq
|
67
|
+
set_window_interval(interval)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Increments in the instance member in the sorted set under
|
72
|
+
# {Keyspace#hits_time_window}.
|
73
|
+
# @param [:hours, :days, :months] interval
|
74
|
+
# @param [true, false] uniq
|
75
|
+
# Changes keyspace scope to keys under .uniq
|
76
|
+
def set_window_interval(interval, uniq = false)
|
77
|
+
key = keyspace(uniq).hits_time_window(@type, interval, @now)
|
78
|
+
redis.zincrby(key, 1, @member)
|
79
|
+
redis.expire(key, @tracker.config.send("#{interval}_window_secs"))
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
module Boffin
|
2
|
+
# Responsible for generating keys to store hit data in Redis.
|
3
|
+
class Keyspace
|
4
|
+
|
5
|
+
attr_reader :config
|
6
|
+
|
7
|
+
# @param [Tracker] tracker
|
8
|
+
# The Tracker that is using this Keyspace
|
9
|
+
# @param [true, false] is_uniq
|
10
|
+
# If specified all keys will include .uniq after the root portion. Used
|
11
|
+
# for easily scoping data for tracking unique hits.
|
12
|
+
def initialize(tracker, is_uniq = false)
|
13
|
+
@config = tracker.config
|
14
|
+
@ns = tracker.namespace
|
15
|
+
@uniq = is_uniq ? true : false
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [true, false]
|
19
|
+
# `true` if this keyspace is scoped for unique data
|
20
|
+
def unique_namespace?
|
21
|
+
@uniq
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param [Object] instance
|
25
|
+
# Object that will be used to prefix the key namespace this is used for
|
26
|
+
# keys that deal with object instances. (See {Utils#object_as_key})
|
27
|
+
# @return [String]
|
28
|
+
# The root portion of a key: `boffin:listing`, `boffin:listing.5`, or
|
29
|
+
# `boffin:listing.5:uniq`
|
30
|
+
def root(instance = nil)
|
31
|
+
slug = instance ? Utils.object_as_key(instance) : nil
|
32
|
+
"#{@config.namespace}:#{@ns}".tap { |s|
|
33
|
+
s << ".#{slug}" if slug
|
34
|
+
s << ":uniq" if @uniq }
|
35
|
+
end
|
36
|
+
|
37
|
+
# @param [Array<Symbol>, Symbol] types
|
38
|
+
# An array of hit types `[:views, :likes]`, or a singular hit type
|
39
|
+
# `:views`
|
40
|
+
# @param [Object] instance
|
41
|
+
# If provided, the keyspace will be scoped to a particilar instance, used
|
42
|
+
# for hit counters: `boffin:listing.<instance>`
|
43
|
+
# @return [String]
|
44
|
+
# The root portion of the hits keyspace: `boffin:listing:views`,
|
45
|
+
# `boffin:listing.5:views`
|
46
|
+
def hits_root(types, instance = nil)
|
47
|
+
"#{root(instance)}:#{[types].flatten.join('_')}"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Calculates the hit root and postfixes it with ":hits", this key is used
|
51
|
+
# for a sorted set that stores unique hit count data.
|
52
|
+
# @param [Array<Symbol>, Symbol] types
|
53
|
+
# @param [Object] instance
|
54
|
+
# @return [String]
|
55
|
+
# @see #hits_root
|
56
|
+
def hits(types, instance = nil)
|
57
|
+
"#{hits_root(types, instance)}:hits"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Calculates the hit root and postfixes it with ":hit_count", this key is
|
61
|
+
# used to store a count of total hits ever made.
|
62
|
+
# @param [Array<Symbol>, Symbol] types
|
63
|
+
# @param [Object] instance
|
64
|
+
# @return [String]
|
65
|
+
# @see #hits_root
|
66
|
+
def hit_count(types, instance)
|
67
|
+
"#{hits_root(types, instance)}:hit_count"
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns a key that is used for storing the result set of a union for the
|
71
|
+
# provided window of time. Calls {#hits}, and then appends
|
72
|
+
# `:current.<unit>_<size>`
|
73
|
+
# @param [Array<Symbol>, Symbol] types
|
74
|
+
# @param [Symbol] unit
|
75
|
+
# The time interval: `:hours`, `:days`, or `:months`
|
76
|
+
# @param [Fixnum] size
|
77
|
+
# The window size of the specified time interval being calculated
|
78
|
+
# @return [String]
|
79
|
+
# @see #hits
|
80
|
+
def hits_union(types, unit, size)
|
81
|
+
"#{hits(types)}:current.#{unit}_#{size}"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Returns a key that is used for storing the result set of a union of hit
|
85
|
+
# unions.
|
86
|
+
# @param [Hash] weighted_hit_types
|
87
|
+
# The types and weights of hits of which a union was calculated:
|
88
|
+
# `{ views: 1, likes: 2 }`
|
89
|
+
# @param [Symbol] unit
|
90
|
+
# The time interval: `:hours`, `:days`, or `:months`
|
91
|
+
# @param [Fixnum] size
|
92
|
+
# The window size of the specified time interval being calculated
|
93
|
+
# @return [String]
|
94
|
+
# @see #hits_union
|
95
|
+
def hits_union_multi(weighted_hit_types, unit, size)
|
96
|
+
types = weighted_hit_types.map { |type, weight| "#{type}_#{weight}" }
|
97
|
+
hits_union(types, unit, size)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Generates a key that is used for storing hit data for a particular
|
101
|
+
# interval in time. You'll probably want to use {#hits_time_window} as it
|
102
|
+
# will generate the window string for you.
|
103
|
+
# @param [Symbol] type
|
104
|
+
# The hit type that will use the generated key
|
105
|
+
# @param [String] window
|
106
|
+
# Represents a period of time: `"2011-01-01-01"`, `"2011-01-01"`,
|
107
|
+
# `"2011-01"`
|
108
|
+
# @return [String]
|
109
|
+
# @see #hits
|
110
|
+
# @see #hits_time_window
|
111
|
+
def hits_window(type, window)
|
112
|
+
"#{hits(type)}.#{window}"
|
113
|
+
end
|
114
|
+
|
115
|
+
# Generates keys for each interval in the calculated range of time
|
116
|
+
# @param [Symbol] type
|
117
|
+
# The hit type that will use the generated key
|
118
|
+
# @param [Symbol] unit
|
119
|
+
# The time interval: `:hours`, `:days`, or `:months`
|
120
|
+
# @param [Fixnum] size
|
121
|
+
# The window size of the specified time interval being calculated
|
122
|
+
# @param [Time, Date] starting_at
|
123
|
+
# The time at which to start counting back from
|
124
|
+
# @return [Array<String>]
|
125
|
+
# An array of keys for the range of intervals
|
126
|
+
# @see #hits_time_window
|
127
|
+
# @see Utils#time_ago_range
|
128
|
+
def hit_time_windows(type, unit, size, starting_at = Time.now)
|
129
|
+
Utils.time_ago_range(starting_at, unit => size).
|
130
|
+
map { |time| hits_time_window(type, unit, time) }
|
131
|
+
end
|
132
|
+
|
133
|
+
# Generates a key for a sorted set that is used to store hit data for a
|
134
|
+
# particular interval of time. For example, the `:days` interval of
|
135
|
+
# 2011-08-26 11:24:41 would be 2011-08-26.
|
136
|
+
# @param [Symbol] type
|
137
|
+
# The hit type that will use the generated key
|
138
|
+
# @param [Symbol] unit
|
139
|
+
# The time interval: `:hours`, `:days`, or `:months`
|
140
|
+
# @param [Time] time
|
141
|
+
# The time for which to extract an interval from.
|
142
|
+
def hits_time_window(type, unit, time)
|
143
|
+
hits_window(type, time.strftime(INTERVAL_FORMATS[unit]))
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Boffin
|
2
|
+
# Can be included into a class that responds to `#as_member`, `#to_i`, or
|
3
|
+
# `#to_s`. It's recommended to use {Boffin.track} to inject Trackable into a
|
4
|
+
# class. It provides the instance methods of Tracker scoped to the host class
|
5
|
+
# and its instances.
|
6
|
+
#
|
7
|
+
# @example
|
8
|
+
# class MyModel < ActiveRecord::Base
|
9
|
+
# include Boffin::Trackable
|
10
|
+
# boffin.hit_types = [:views, :likes]
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# # Then record hits to instances of your model
|
14
|
+
# @my_model = MyModel.find(1)
|
15
|
+
# @my_model.hit(:views)
|
16
|
+
#
|
17
|
+
# See {file:README} for more examples.
|
18
|
+
module Trackable
|
19
|
+
|
20
|
+
# @private
|
21
|
+
def self.included(mod)
|
22
|
+
mod.extend(ClassMethods)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Included as class methods in the host class
|
26
|
+
module ClassMethods
|
27
|
+
# @return [Tracker] The Tracker instance associated with the class
|
28
|
+
def boffin
|
29
|
+
@boffin ||= ::Boffin::Tracker.new(self)
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param [Symbol, Hash] type_or_weights
|
33
|
+
# @param [Hash] opts
|
34
|
+
# @return [Array<String>, Array<Array>]
|
35
|
+
# @see Tracker#top
|
36
|
+
def top_ids(type_or_weights, opts = {})
|
37
|
+
boffin.top(type_or_weights, opts)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# @see Tracker#hit
|
42
|
+
# @return [Hit]
|
43
|
+
def hit(type, uniquenesses = [])
|
44
|
+
self.class.boffin.hit(type, self, uniquenesses)
|
45
|
+
end
|
46
|
+
|
47
|
+
# @see Tracker#hit_count
|
48
|
+
# @return [Fixnum]
|
49
|
+
def hit_count(type)
|
50
|
+
self.class.boffin.hit_count(type, self)
|
51
|
+
end
|
52
|
+
|
53
|
+
# @see Tracker#uhit_count
|
54
|
+
# @return [Fixnum]
|
55
|
+
def uhit_count(type)
|
56
|
+
self.class.boffin.uhit_count(type, self)
|
57
|
+
end
|
58
|
+
|
59
|
+
# @see Tracker#hit_count_for_session_id
|
60
|
+
# @return [Fixnum]
|
61
|
+
def hit_count_for_session_id(type, sess_obj)
|
62
|
+
self.class.boffin.hit_count_for_session_id(type, self, sess_obj)
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,229 @@
|
|
1
|
+
module Boffin
|
2
|
+
class Tracker
|
3
|
+
|
4
|
+
attr_reader :namespace, :config
|
5
|
+
attr_accessor :hit_types
|
6
|
+
|
7
|
+
# @param [String, Symbol, #to_s] class_or_ns
|
8
|
+
# A string, symbol or any object that responds to `#to_s` that will be
|
9
|
+
# used to namespace this keys of this Tracker.
|
10
|
+
# @param [Array<Symbol>] hit_types
|
11
|
+
# A list of hit types that this Tracker will allow, if empty then any
|
12
|
+
# hit type will be allowed.
|
13
|
+
# @param [Config] config
|
14
|
+
# A Config instance to use instead of Boffin.config
|
15
|
+
# @example
|
16
|
+
# Tracker.new(MyModel, [:views, likes])
|
17
|
+
# Tracker.new(:urls, [:shares, :clicks])
|
18
|
+
def initialize(class_or_ns, hit_types = [], config = Boffin.config.dup)
|
19
|
+
@namespace = Utils.object_as_namespace(class_or_ns)
|
20
|
+
@hit_types = hit_types
|
21
|
+
@config = config
|
22
|
+
@keyspace = Keyspace.new(self)
|
23
|
+
@ukeyspace = Keyspace.new(self, true)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param [Symbol] hit_type
|
27
|
+
# @param [#as_member, #id, #to_s] instance
|
28
|
+
# @param [Array] uniquenesses
|
29
|
+
# @return [Hit]
|
30
|
+
# @raise Boffin::UndefinedHitTypeError
|
31
|
+
# Raised if a list of hit types is available and the provided hit type is
|
32
|
+
# not in the list.
|
33
|
+
def hit(hit_type, instance, uniquenesses = [])
|
34
|
+
validate_hit_type(hit_type)
|
35
|
+
Hit.new(self, hit_type, instance, uniquenesses)
|
36
|
+
end
|
37
|
+
|
38
|
+
# @param [Symbol] hit_type
|
39
|
+
# @param [#as_member, #id, #to_s] instance
|
40
|
+
# @return [Fixnum]
|
41
|
+
# @raise Boffin::UndefinedHitTypeError
|
42
|
+
# Raised if a list of hit types is available and the provided hit type is
|
43
|
+
# not in the list.
|
44
|
+
def hit_count(hit_type, instance)
|
45
|
+
validate_hit_type(hit_type)
|
46
|
+
redis.get(keyspace.hit_count(hit_type, instance)).to_i
|
47
|
+
end
|
48
|
+
|
49
|
+
# @param [Symbol] hit_type
|
50
|
+
# @param [#as_member, #id, #to_s] instance
|
51
|
+
# @return [Fixnum]
|
52
|
+
# @raise Boffin::UndefinedHitTypeError
|
53
|
+
# Raised if a list of hit types is available and the provided hit type is
|
54
|
+
# not in the list.
|
55
|
+
def uhit_count(hit_type, instance)
|
56
|
+
validate_hit_type(hit_type)
|
57
|
+
redis.zcard(keyspace.hits(hit_type, instance)).to_i
|
58
|
+
end
|
59
|
+
|
60
|
+
# @param [Symbol] hit_type
|
61
|
+
# @param [#as_member, #id, #to_s] instance
|
62
|
+
# @param [#as_member, #id, #to_s] sess_obj
|
63
|
+
# @return [Fixnum]
|
64
|
+
# @raise Boffin::UndefinedHitTypeError
|
65
|
+
# Raised if a list of hit types is available and the provided hit type is
|
66
|
+
# not in the list.
|
67
|
+
def hit_count_for_session_id(hit_type, instance, sess_obj)
|
68
|
+
validate_hit_type(hit_type)
|
69
|
+
sessid = Utils.object_as_session_identifier(sess_obj)
|
70
|
+
redis.zscore(keyspace.hits(hit_type, instance), sessid).to_i
|
71
|
+
end
|
72
|
+
|
73
|
+
# Performs set union across the specified number of hours, days, or months
|
74
|
+
# to calculate the members with the highest hit counts. The operation can
|
75
|
+
# be performed on one hit type, or multiple hit types with weights.
|
76
|
+
# @param [Symbol, Hash] type_or_weights
|
77
|
+
# When Hash the set union is calculated
|
78
|
+
# @param [Hash] opts
|
79
|
+
# @option opts [true, false] :unique (false)
|
80
|
+
# If `true` then only unique hits are considered in the calculation
|
81
|
+
# @option opts [true, false] :counts (false)
|
82
|
+
# If `true` then scores are returned along with the top members
|
83
|
+
# @option opts [:desc, :asc] :order (:desc)
|
84
|
+
# The order of the results, in decending (most hits to least hits) or
|
85
|
+
# ascending (least hits to most hits) order.
|
86
|
+
# @option opts [Fixnum] :hours
|
87
|
+
# Perform union for hit counts over the last _n_ hours.
|
88
|
+
# @option opts [Fixnum] :days
|
89
|
+
# Perform union for hit counts over the last _n_ days.
|
90
|
+
# @option opts [Fixnum] :months
|
91
|
+
# Perform union for hit counts over the last _n_ months.
|
92
|
+
# @example Return IDs of most viewed and liked listings in the past 6 days with scores
|
93
|
+
# @tracker.top({ views: 1, likes: 1 }, counts: true, days: 6)
|
94
|
+
# @example Return IDs of most viewed listings in the past 12 hours
|
95
|
+
# @tracker.top(:views, hours: 12)
|
96
|
+
# @note
|
97
|
+
# The result set returned is cached in Redis for the duration of
|
98
|
+
# {Config#cache_expire_secs}
|
99
|
+
# @note
|
100
|
+
# Only one of `:hours`, `:days`, or `:months` should be specified in the
|
101
|
+
# options hash as they can not be combined.
|
102
|
+
# @raise Boffin::UndefinedHitTypeError
|
103
|
+
# If a list of hit types is available and any of the provided hit types is
|
104
|
+
# not in the list.
|
105
|
+
def top(type_or_weights, opts = {})
|
106
|
+
validate_hit_type(type_or_weights)
|
107
|
+
unit, size = *Utils.extract_time_unit(opts)
|
108
|
+
keyspace = keyspace(opts[:unique])
|
109
|
+
if type_or_weights.is_a?(Hash)
|
110
|
+
multiunion(keyspace, type_or_weights, unit, size, opts)
|
111
|
+
else
|
112
|
+
union(keyspace, type_or_weights, unit, size, opts)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# @param [true, false] uniq
|
117
|
+
# If `true` the unique-scoped keyspace is returned
|
118
|
+
# @return [Keyspace]
|
119
|
+
# Keyspace associated with this tracker
|
120
|
+
def keyspace(uniq = false)
|
121
|
+
uniq ? @ukeyspace : @keyspace
|
122
|
+
end
|
123
|
+
|
124
|
+
# @return [Redis] The Redis connection for this Tracker's config
|
125
|
+
def redis
|
126
|
+
@config.redis
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
# Checks to see if `hit_type` exists in the list of hit types. If no
|
132
|
+
# elements exist in @hit_types then the check is skipped.
|
133
|
+
# @param [Symbol] hit_type
|
134
|
+
# @raise Boffin::UndefinedHitTypeError
|
135
|
+
# Raised if a list of hit types is available and the provided hit type is
|
136
|
+
# not in the list.
|
137
|
+
def validate_hit_type(hit_type)
|
138
|
+
return if @hit_types.empty?
|
139
|
+
(hit_type.is_a?(Hash) ? hit_type.keys : [hit_type]).each do |type|
|
140
|
+
next if @hit_types.include?(type)
|
141
|
+
raise UndefinedHitTypeError, "#{type} is not in the list of " \
|
142
|
+
"valid hit types for this Tracker, valid types are: " \
|
143
|
+
"#{@hit_types.inspect}"
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# @param [Keyspace] ks
|
148
|
+
# Keyspace to perform the union on
|
149
|
+
# @param [Symbol] hit_type
|
150
|
+
# @param [:hours, :days, :months] unit
|
151
|
+
# @param [Fixnum] size
|
152
|
+
# Number of intervals to include in the union
|
153
|
+
# @param [Hash] opts
|
154
|
+
# @option opts [true, false] :counts (false)
|
155
|
+
# @option opts [:asc, :desc] :order (:desc)
|
156
|
+
# @return [Array<String>, Array<Array>]
|
157
|
+
# @see #zfetch
|
158
|
+
def union(ks, hit_type, unit, size, opts = {})
|
159
|
+
keys = ks.hit_time_windows(hit_type, unit, size)
|
160
|
+
zfetch(ks.hits_union(hit_type, unit, size), keys, opts)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Performs {#union} for each hit type, then performs a union on those
|
164
|
+
# result sets with the provided weights.
|
165
|
+
# @param [Keyspace] ks
|
166
|
+
# Keyspace to perform the union on
|
167
|
+
# @param [Hash] weights
|
168
|
+
# @param [Symbol] hit_type
|
169
|
+
# @param [:hours, :days, :months] unit
|
170
|
+
# @param [Fixnum] size
|
171
|
+
# Number of intervals to include in the union
|
172
|
+
# @param [Hash] opts
|
173
|
+
# @option opts [true, false] :counts (false)
|
174
|
+
# @option opts [:asc, :desc] :order (:desc)
|
175
|
+
# @return [Array<String>, Array<Array>]
|
176
|
+
# @see #zfetch
|
177
|
+
def multiunion(ks, weights, unit, size, opts = {})
|
178
|
+
weights.keys.each { |t| union(ks, t, unit, size, opts) }
|
179
|
+
keys = weights.keys.map { |t| ks.hits_union(t, unit, size) }
|
180
|
+
zfetch(ks.hits_union_multi(weights, unit, size), keys, {
|
181
|
+
weights: weights.values
|
182
|
+
}.merge(opts))
|
183
|
+
end
|
184
|
+
|
185
|
+
# Checks to see if the result set exists (is cached), if it does the set is
|
186
|
+
# returned, otherwise a union of the keys is performed, cached, and
|
187
|
+
# returned.
|
188
|
+
# @param [String] storkey
|
189
|
+
# Key to store the result set under
|
190
|
+
# @param [Array<String>] keys
|
191
|
+
# @param [Hash] opts
|
192
|
+
# @option opts [true, false] :counts (false)
|
193
|
+
# @option opts [:asc, :desc] :order (:desc)
|
194
|
+
# @see #zrange
|
195
|
+
def zfetch(storkey, keys, opts = {})
|
196
|
+
zrangeopts = {
|
197
|
+
counts: opts.delete(:counts),
|
198
|
+
order: (opts.delete(:order) || :desc).to_sym }
|
199
|
+
if redis.zcard(storkey) == 0
|
200
|
+
redis.zunionstore(storkey, keys, opts)
|
201
|
+
redis.expire(storkey, @config.cache_expire_secs)
|
202
|
+
end
|
203
|
+
zrange(storkey, zrangeopts)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Performs a range on a sorted set at key.
|
207
|
+
# @param [String] key
|
208
|
+
# @param [Hash] opts
|
209
|
+
# @option opts [true, false] :counts (false)
|
210
|
+
# @option opts [:asc, :desc] :order (:desc)
|
211
|
+
# @return [Array<String>, Array<Array>]
|
212
|
+
# Returns an array of members in sorted order, optionally if the `:counts`
|
213
|
+
# option is `true` it returns an array of pairs where the first value is
|
214
|
+
# the member, and the second value is the member's score.
|
215
|
+
def zrange(key, opts)
|
216
|
+
args = [key, 0, -1, opts[:counts] ? { withscores: true } : {}]
|
217
|
+
result = case opts[:order]
|
218
|
+
when :asc then redis.zrange(*args)
|
219
|
+
when :desc then redis.zrevrange(*args)
|
220
|
+
end
|
221
|
+
if opts[:counts]
|
222
|
+
result.each_slice(2).map { |mbr, score| [mbr, score.to_i] }
|
223
|
+
else
|
224
|
+
result
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
end
|
229
|
+
end
|