boffin 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.
- 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
|