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