redis-time-series 0.2.0 → 0.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,13 @@
1
1
  require 'bigdecimal'
2
2
  require 'forwardable'
3
- require 'time/msec'
3
+ require 'ext/time_msec'
4
+ require 'redis/time_series/client'
5
+ require 'redis/time_series/errors'
6
+ require 'redis/time_series/aggregation'
7
+ require 'redis/time_series/filters'
8
+ require 'redis/time_series/rule'
4
9
  require 'redis/time_series/info'
5
- require 'redis/time_series'
6
10
  require 'redis/time_series/sample'
11
+ require 'redis/time_series'
7
12
 
8
- class RedisTimeSeries
9
- VERSION = '0.2.0'
10
- end
13
+ class RedisTimeSeries; end
@@ -1,82 +1,234 @@
1
1
  # frozen_string_literal: true
2
+ using TimeMsec
3
+
2
4
  class Redis
5
+ # The +Redis::TimeSeries+ class is an interface for working with time-series data in
6
+ # Redis, using the {https://oss.redislabs.com/redistimeseries RedisTimeSeries} module.
7
+ #
8
+ # You can't use this gem with vanilla Redis, the time series module must be compiled
9
+ # and loaded. The easiest way to do this is by running the provided Docker container.
10
+ # Refer to the {https://oss.redislabs.com/redistimeseries/#setup setup guide} for more info.
11
+ #
12
+ # +docker run -p 6379:6379 -it --rm redislabs/redistimeseries+
13
+ #
14
+ # Once you're up and running, you can create a new time series and start recording data.
15
+ # Many commands are documented below, but you should refer to the
16
+ # {https://oss.redislabs.com/redistimeseries/commands command documentation} for the most
17
+ # authoritative and up-to-date reference.
18
+ #
19
+ # @example
20
+ # ts = Redis::TimeSeries.create('time_series_example')
21
+ # ts.add(12345)
22
+ # ts.get
23
+ # #=> #<Redis::TimeSeries::Sample:0x00007ff00d942e60 @time=2020-07-19 16:52:48 -0700, @value=0.12345e5>
3
24
  class TimeSeries
25
+ extend Client
4
26
  extend Forwardable
5
27
 
6
28
  class << self
29
+ # Create a new time series.
30
+ #
31
+ # @param key [String] the Redis key to store time series data in
32
+ # @option options [Hash] :labels
33
+ # A hash of label-value pairs to apply to this series.
34
+ # @option options [Redis] :redis (self.class.redis) a different Redis client to use
35
+ # @option options [Integer] :retention
36
+ # Maximum age for samples compared to last event time (in milliseconds).
37
+ # With no value, the series will not be trimmed.
38
+ # @option options [Boolean] :uncompressed
39
+ # When true, series data will be stored in an uncompressed format.
40
+ #
41
+ # @return [Redis::TimeSeries] the created time series
42
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tscreate
7
43
  def create(key, **options)
8
- new(key, **options).create(labels: options[:labels])
44
+ new(key, redis: options.fetch(:redis, redis)).create(**options)
45
+ end
46
+
47
+ # Create a compaction rule for a series. Note that both source and destination series
48
+ # must exist before the rule can be created.
49
+ #
50
+ # @param source [String, TimeSeries] the source series (or key) to apply the rule to
51
+ # @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
52
+ # @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
53
+ # The aggregation to apply. Can be an {Aggregation} object, or an array of
54
+ # aggregation_type and duration +[:avg, 120000]+
55
+ #
56
+ # @return [String] the string "OK"
57
+ # @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
58
+ # @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
59
+ #
60
+ # @see TimeSeries#create_rule
61
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tscreaterule
62
+ def create_rule(source:, dest:, aggregation:)
63
+ cmd 'TS.CREATERULE', key_for(source), key_for(dest), Aggregation.parse(aggregation).to_a
64
+ end
65
+
66
+ # Delete an existing compaction rule.
67
+ #
68
+ # @param source [String, TimeSeries] the source series (or key) to remove the rule from
69
+ # @param dest [String, TimeSeries] the destination series (or key) the rule applies to
70
+ #
71
+ # @return [String] the string "OK"
72
+ # @raise [Redis::CommandError] if the compaction rule does not exist
73
+ def delete_rule(source:, dest:)
74
+ cmd 'TS.DELETERULE', key_for(source), key_for(dest)
75
+ end
76
+
77
+ # Delete all data and remove a time series from Redis.
78
+ #
79
+ # @param key [String] the key to remove
80
+ # @return [1] if the series existed
81
+ # @return [0] if the series did not exist
82
+ def destroy(key)
83
+ redis.del key
9
84
  end
10
85
 
11
86
  def madd(data)
12
87
  data.reduce([]) do |memo, (key, value)|
13
- if value.is_a?(Hash) || (value.is_a?(Array) && value.first.is_a?(Array))
14
- # multiple timestamp => value pairs
15
- value.each do |timestamp, nested_value|
16
- timestamp = timestamp.ts_msec if timestamp.is_a? Time
17
- memo << [key, timestamp, nested_value]
18
- end
19
- elsif value.is_a? Array
20
- # single [timestamp, value]
21
- key = key.ts_msec if key.is_a? Time
22
- memo << [key, value]
23
- else
24
- # single value, no timestamp
25
- memo << [key, '*', value]
26
- end
88
+ memo << parse_madd_values(key, value)
27
89
  memo
28
90
  end.then do |args|
29
- puts "DEBUG: TS.MADD #{args.join(' ')}" if ENV['DEBUG']
30
- redis.call('TS.MADD', args.flatten).each_with_index.map do |result, idx|
91
+ cmd('TS.MADD', args).each_with_index.map do |result, idx|
31
92
  result.is_a?(Redis::CommandError) ? result : Sample.new(result, args[idx][2])
32
93
  end
33
94
  end
34
95
  end
35
96
 
36
- def redis
37
- @redis ||= Redis.current
97
+ # Search for a time series matching the provided filters. Refer to the {Filters} documentation
98
+ # for more details on how to filter.
99
+ #
100
+ # @example Using a filter string
101
+ # Redis::TimeSeries.query_index('foo=bar')
102
+ # #=> [#<Redis::TimeSeries:0x00007ff00e222788 @key="ts3", @redis=#<Redis...>>]
103
+ # @example Using the .where alias with hash DSL
104
+ # Redis::TimeSeries.where(foo: 'bar')
105
+ # #=> [#<Redis::TimeSeries:0x00007ff00e2a1d30 @key="ts3", @redis=#<Redis...>>]
106
+ #
107
+ # @param filter_value [Hash, String] a set of filters to query with
108
+ # @return [Array<TimeSeries>] an array of series that matched the given filters
109
+ #
110
+ # @see Filters
111
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsqueryindex
112
+ # @see https://oss.redislabs.com/redistimeseries/commands/#filtering
113
+ def query_index(filter_value)
114
+ filters = Filters.new(filter_value)
115
+ filters.validate!
116
+ cmd('TS.QUERYINDEX', filters.to_a).map { |key| new(key) }
117
+ end
118
+ alias where query_index
119
+
120
+ private
121
+
122
+ def key_for(series_or_string)
123
+ series_or_string.is_a?(self) ? series_or_string.key : series_or_string.to_s
38
124
  end
39
125
 
40
- def redis=(client)
41
- @redis = redis
126
+ def parse_madd_values(key, raw)
127
+ if raw.is_a?(Hash) || (raw.is_a?(Array) && raw.first.is_a?(Array))
128
+ # multiple timestamp => value pairs
129
+ raw.map do |timestamp, value|
130
+ [key, timestamp, value]
131
+ end
132
+ elsif raw.is_a? Array
133
+ # single [timestamp, value]
134
+ [key, raw.first, raw.last]
135
+ else
136
+ # single value, no timestamp
137
+ [key, '*', raw]
138
+ end
42
139
  end
43
140
  end
44
141
 
45
- attr_reader :key, :redis, :retention, :uncompressed
142
+ # @return [String] the Redis key this time series is stored in
143
+ attr_reader :key
46
144
 
47
- def initialize(key, options = {})
145
+ # @param key [String] the Redis key to store the time series in
146
+ # @param redis [Redis] an optional Redis client
147
+ def initialize(key, redis: self.class.redis)
48
148
  @key = key
49
- @redis = options[:redis] || self.class.redis
50
- @retention = options[:retention]
51
- @uncompressed = options[:uncompressed] || false
149
+ @redis = redis
52
150
  end
53
151
 
54
- def add(value, timestamp = '*')
55
- timestamp = timestamp.ts_msec if timestamp.is_a? Time
56
- ts = cmd 'TS.ADD', key, timestamp, value
152
+ # Add a value to the series.
153
+ #
154
+ # @param value [Numeric] the value to add
155
+ # @param timestamp [Time, Numeric] the +Time+, or integer timestamp in milliseconds, to add the value
156
+ # @param uncompressed [Boolean] if true, stores data in an uncompressed format
157
+ #
158
+ # @return [Sample] the value that was added
159
+ # @raise [Redis::CommandError] if the value being added is older than the latest timestamp in the series
160
+ def add(value, timestamp = '*', uncompressed: nil)
161
+ ts = cmd 'TS.ADD', key, timestamp, value, ('UNCOMPRESSED' if uncompressed)
57
162
  Sample.new(ts, value)
58
163
  end
59
164
 
60
- def create(labels: nil)
61
- args = [key]
62
- args << ['RETENTION', retention] if retention
63
- args << 'UNCOMPRESSED' if uncompressed
64
- args << ['LABELS', labels.to_a] if labels&.any?
65
- cmd 'TS.CREATE', args.flatten
165
+ # Issues a TS.CREATE command for the current series.
166
+ # You should use class method {Redis::TimeSeries.create} instead.
167
+ # @api private
168
+ def create(retention: nil, uncompressed: nil, labels: nil)
169
+ cmd 'TS.CREATE', key,
170
+ (['RETENTION', retention] if retention),
171
+ ('UNCOMPRESSED' if uncompressed),
172
+ (['LABELS', labels.to_a] if labels&.any?)
66
173
  self
67
174
  end
68
175
 
69
- def decrby(value = 1, timestamp = nil)
70
- args = [key, value]
71
- args << timestamp if timestamp
72
- cmd 'TS.DECRBY', args
176
+ # Create a compaction rule for this series.
177
+ #
178
+ # @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
179
+ # @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
180
+ # The aggregation to apply. Can be an {Aggregation} object, or an array of
181
+ # aggregation_type and duration +[:avg, 120000]+
182
+ #
183
+ # @return [String] the string "OK"
184
+ # @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
185
+ # @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
186
+ #
187
+ # @see TimeSeries.create_rule
188
+ def create_rule(dest:, aggregation:)
189
+ self.class.create_rule(source: self, dest: dest, aggregation: aggregation)
190
+ end
191
+
192
+ # Delete an existing compaction rule.
193
+ #
194
+ # @param dest [String, TimeSeries] the destination series (or key) the rule applies to
195
+ #
196
+ # @return [String] the string "OK"
197
+ # @raise [Redis::CommandError] if the compaction rule does not exist
198
+ #
199
+ # @see TimeSeries.delete_rule
200
+ def delete_rule(dest:)
201
+ self.class.delete_rule(source: self, dest: dest)
202
+ end
203
+
204
+ # Decrement the current value of the series.
205
+ #
206
+ # @param value [Integer] the amount to decrement by
207
+ # @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
208
+ # @param uncompressed [Boolean] if true, stores data in an uncompressed format
209
+ #
210
+ # @return [Integer] the timestamp the value was stored at
211
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
212
+ def decrby(value = 1, timestamp = nil, uncompressed: nil)
213
+ cmd 'TS.DECRBY', key, value, (timestamp if timestamp), ('UNCOMPRESSED' if uncompressed)
73
214
  end
74
215
  alias decrement decrby
75
216
 
217
+
218
+ # Delete all data and remove this time series from Redis.
219
+ #
220
+ # @return [1] if the series existed
221
+ # @return [0] if the series did not exist
76
222
  def destroy
77
223
  redis.del key
78
224
  end
79
225
 
226
+ # Get the most recent sample for this series.
227
+ #
228
+ # @return [Sample] the most recent sample for this series
229
+ # @return [nil] if there are no samples in the series
230
+ #
231
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsget
80
232
  def get
81
233
  cmd('TS.GET', key).then do |timestamp, value|
82
234
  return unless value
@@ -84,28 +236,46 @@ class Redis
84
236
  end
85
237
  end
86
238
 
87
- def incrby(value = 1, timestamp = nil)
88
- args = [key, value]
89
- args << timestamp if timestamp
90
- cmd 'TS.INCRBY', args
239
+ # Increment the current value of the series.
240
+ #
241
+ # @param value [Integer] the amount to increment by
242
+ # @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
243
+ # @param uncompressed [Boolean] if true, stores data in an uncompressed format
244
+ #
245
+ # @return [Integer] the timestamp the value was stored at
246
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
247
+ def incrby(value = 1, timestamp = nil, uncompressed: nil)
248
+ cmd 'TS.INCRBY', key, value, (timestamp if timestamp), ('UNCOMPRESSED' if uncompressed)
91
249
  end
92
250
  alias increment incrby
93
251
 
252
+ # Get information about the series.
253
+ # Note that all properties of {Info} are also available on the series itself
254
+ # via delegation.
255
+ #
256
+ # @return [Info] an info object about the current series
257
+ #
258
+ # @see Info
259
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsinfo
94
260
  def info
95
- cmd('TS.INFO', key).then(&Info.method(:parse))
261
+ Info.parse series: self, data: cmd('TS.INFO', key)
96
262
  end
97
- def_delegators :info, *Info.members
98
- %i[count length size].each { |m| def_delegator :info, :total_samples, m }
263
+ def_delegators :info, *Info.members - [:series] + %i[count length size source]
99
264
 
265
+ # Assign labels to the series using +TS.ALTER+
266
+ #
267
+ # @param val [Hash] a hash of label-value pairs
268
+ # @return [Hash] the assigned labels
269
+ #
270
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
100
271
  def labels=(val)
101
- cmd 'TS.ALTER', key, 'LABELS', val.to_a.flatten
272
+ cmd 'TS.ALTER', key, 'LABELS', val.to_a
102
273
  end
103
274
 
104
275
  def madd(*values)
105
276
  if values.one? && values.first.is_a?(Hash)
106
277
  # Hash of timestamp => value pairs
107
278
  args = values.first.map do |ts, val|
108
- ts = ts.ts_msec if ts.is_a? Time
109
279
  [key, ts, val]
110
280
  end.flatten
111
281
  elsif values.one? && values.first.is_a?(Array)
@@ -125,30 +295,50 @@ class Redis
125
295
  cmd 'TS.MADD', args
126
296
  end
127
297
 
128
- def range(range, count: nil, agg: nil)
129
- if range.is_a? Hash
130
- args = range.fetch(:from), range.fetch(:to)
131
- elsif range.is_a? Range
132
- args = range.min, range.max
133
- end
134
- args.map! { |ts| (ts.to_f * 1000).to_i }
135
- args.append('COUNT', count) if count
136
- args.append('AGGREGATION', agg) if agg
137
- cmd('TS.RANGE', key, args).map do |ts, val|
138
- Sample.new(ts, val)
298
+ # Get a range of values from the series
299
+ #
300
+ # @param range [Hash, Range] a time range, or hash of +from+ and +to+ values
301
+ # @param count [Integer] the maximum number of results to return
302
+ # @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
303
+ # The aggregation to apply. Can be an {Aggregation} object, or an array of
304
+ # aggregation_type and duration +[:avg, 120000]+
305
+ #
306
+ # @return [Array<Sample>] an array of samples matching the range query
307
+ #
308
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsrangetsrevrange
309
+ def range(range, count: nil, aggregation: nil)
310
+ if range.is_a?(Hash)
311
+ # This is to support from: and to: passed in as hash keys
312
+ # `range` will swallow all parameters if they're all hash syntax
313
+ count = range.delete(:count)
314
+ aggregation = range.delete(:aggregation)
315
+ range = range.fetch(:from)..range.fetch(:to)
139
316
  end
317
+ cmd('TS.RANGE',
318
+ key,
319
+ range.min,
320
+ range.max,
321
+ (['COUNT', count] if count),
322
+ Aggregation.parse(aggregation)&.to_a
323
+ ).map { |ts, val| Sample.new(ts, val) }
140
324
  end
141
325
 
326
+ # Set data retention time for the series using +TS.ALTER+
327
+ #
328
+ # @param val [Integer] the number of milliseconds data should be retained. +0+ means retain forever.
329
+ # @return [Integer] the retention value of the series
330
+ #
331
+ # @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
142
332
  def retention=(val)
143
- @retention = val.to_i
333
+ # TODO: this should also accept an ActiveSupport::Duration
144
334
  cmd 'TS.ALTER', key, 'RETENTION', val.to_i
145
335
  end
146
336
 
147
- private
148
-
149
- def cmd(name, *args)
150
- puts "DEBUG: #{name} #{args.join(' ')}" if ENV['DEBUG']
151
- redis.call name, *args
337
+ # Compare series based on Redis key and configured client.
338
+ # @return [Boolean] whether the two TimeSeries objects refer to the same series
339
+ def ==(other)
340
+ return false unless other.is_a?(self.class)
341
+ key == other.key && redis == other.redis
152
342
  end
153
343
  end
154
344
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ class Redis
3
+ class TimeSeries
4
+ # An aggregation is a combination of a mathematical function, and a time window over
5
+ # which to apply that function. In RedisTimeSeries, aggregations are used to downsample
6
+ # data from a source series to a destination series, using compaction rules.
7
+ #
8
+ # @see Redis::TimeSeries#create_rule
9
+ # @see Redis::TimeSeries::Rule
10
+ # @see https://oss.redislabs.com/redistimeseries/commands/#aggregation-compaction-downsampling
11
+ class Aggregation
12
+ TYPES = %w[
13
+ avg
14
+ count
15
+ first
16
+ last
17
+ max
18
+ min
19
+ range
20
+ std.p
21
+ std.s
22
+ sum
23
+ var.p
24
+ var.s
25
+ ]
26
+
27
+ # @return [String] the type of aggregation to apply
28
+ # @see TYPES
29
+ attr_reader :type
30
+ alias aggregation_type type
31
+
32
+ # @return [Integer] the time window to apply the aggregation over, in milliseconds
33
+ attr_reader :duration
34
+ alias time_bucket duration
35
+
36
+ # Parse a method argument into an aggregation.
37
+ #
38
+ # @param agg [Array, Aggregation] an aggregation object, or an array of type and duration +[:avg, 60000]+
39
+ # @return [Aggregation] the parsed aggregation, or the original argument if already an aggregation
40
+ # @raise [AggregationError] when given an unparseable value
41
+ def self.parse(agg)
42
+ return unless agg
43
+ return agg if agg.is_a?(self)
44
+ return new(agg.first, agg.last) if agg.is_a?(Array) && agg.size == 2
45
+ raise AggregationError, "Couldn't parse #{agg} into an aggregation rule!"
46
+ end
47
+
48
+ # Create a new Aggregation given a type and duration.
49
+ # @param type [String, Symbol] one of the valid aggregation {TYPES}
50
+ # @param duration [Integer, ActiveSupport::Duration]
51
+ # A time window to apply this aggregation over.
52
+ # If you're using ActiveSupport, duration objects (e.g. +10.minutes+) will be automatically coerced.
53
+ # @return [Aggregation]
54
+ # @raise [AggregationError] if the given aggregation type is not valid
55
+ def initialize(type, duration)
56
+ type = type.to_s.downcase
57
+ unless TYPES.include? type
58
+ raise AggregationError, "#{type} is not a valid aggregation type!"
59
+ end
60
+ @type = type
61
+ if defined?(ActiveSupport::Duration) && duration.is_a?(ActiveSupport::Duration)
62
+ @duration = duration.in_milliseconds
63
+ else
64
+ @duration = duration.to_i
65
+ end
66
+ end
67
+
68
+ # @api private
69
+ # @return [Array]
70
+ def to_a
71
+ ['AGGREGATION', type, duration]
72
+ end
73
+
74
+ # @api private
75
+ # @return [String]
76
+ def to_s
77
+ to_a.join(' ')
78
+ end
79
+
80
+ # Compares aggregations based on type and duration.
81
+ # @return [Boolean] whether the given aggregations are equivalent
82
+ def ==(other)
83
+ parsed = self.class.parse(other)
84
+ type == parsed.type && duration == parsed.duration
85
+ end
86
+ end
87
+ end
88
+ end