boffin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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