redis-time-series 0.1.1 → 0.5.1

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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The +TimeMsec+ module is a refinement for the +Time+ class that makes it easier
4
+ # to work with millisecond timestamps.
5
+ #
6
+ # @example
7
+ # Time.now.to_i # 1595194259
8
+ # Time.now.ts_msec # NoMethodError
9
+ #
10
+ # using TimeMsec
11
+ #
12
+ # Time.now.to_i # 1595194259
13
+ # Time.now.ts_msec # 1595194259000
14
+ #
15
+ # Time.from_msec(1595194259000) # 2020-07-19 14:30:59 -0700
16
+ module TimeMsec
17
+ refine Time do
18
+ # TODO: convert to #to_msec
19
+ def ts_msec
20
+ (to_f * 1000.0).to_i
21
+ end
22
+ end
23
+
24
+ refine Time.singleton_class do
25
+ def from_msec(timestamp)
26
+ at(timestamp / 1000.0)
27
+ end
28
+ end
29
+ end
@@ -1,8 +1,13 @@
1
1
  require 'bigdecimal'
2
- require 'time/msec'
3
- require 'redis/time_series'
2
+ require 'forwardable'
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'
9
+ require 'redis/time_series/info'
4
10
  require 'redis/time_series/sample'
11
+ require 'redis/time_series'
5
12
 
6
- class RedisTimeSeries
7
- VERSION = '0.1.1'
8
- 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
26
+ extend Forwardable
27
+
4
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
5
43
  def create(key, **options)
6
- new(key, **options).create
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
7
84
  end
8
85
 
9
86
  def madd(data)
10
87
  data.reduce([]) do |memo, (key, value)|
11
- if value.is_a?(Hash) || (value.is_a?(Array) && value.first.is_a?(Array))
12
- # multiple timestamp => value pairs
13
- value.each do |timestamp, nested_value|
14
- timestamp = timestamp.ts_msec if timestamp.is_a? Time
15
- memo << [key, timestamp, nested_value]
16
- end
17
- elsif value.is_a? Array
18
- # single [timestamp, value]
19
- key = key.ts_msec if key.is_a? Time
20
- memo << [key, value]
21
- else
22
- # single value, no timestamp
23
- memo << [key, '*', value]
24
- end
88
+ memo << parse_madd_values(key, value)
25
89
  memo
26
90
  end.then do |args|
27
- puts "DEBUG: TS.MADD #{args.join(' ')}" if ENV['DEBUG']
28
- 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|
29
92
  result.is_a?(Redis::CommandError) ? result : Sample.new(result, args[idx][2])
30
93
  end
31
94
  end
32
95
  end
33
96
 
34
- def redis
35
- @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
36
124
  end
37
125
 
38
- def redis=(client)
39
- @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
40
139
  end
41
140
  end
42
141
 
43
- attr_reader :key, :labels, :redis, :retention, :uncompressed
142
+ # @return [String] the Redis key this time series is stored in
143
+ attr_reader :key
44
144
 
45
- 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)
46
148
  @key = key
47
- # TODO: read labels from redis if not loaded in memory
48
- @labels = options[:labels] || []
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
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,31 +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
 
94
- # TODO: extract Info module, with methods for each property
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
95
260
  def info
96
- cmd('TS.INFO', key).each_slice(2).reduce({}) do |h, (key, value)|
97
- h[key.gsub(/(.)([A-Z])/,'\1_\2').downcase] = value
98
- h
99
- end
261
+ Info.parse series: self, data: cmd('TS.INFO', key)
100
262
  end
263
+ def_delegators :info, *Info.members - [:series] + %i[count length size source]
101
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
102
271
  def labels=(val)
103
- @labels = val
104
- cmd 'TS.ALTER', key, 'LABELS', labels.to_a.flatten
272
+ cmd 'TS.ALTER', key, 'LABELS', val.to_a
105
273
  end
106
274
 
107
275
  def madd(*values)
108
276
  if values.one? && values.first.is_a?(Hash)
109
277
  # Hash of timestamp => value pairs
110
278
  args = values.first.map do |ts, val|
111
- ts = ts.ts_msec if ts.is_a? Time
112
279
  [key, ts, val]
113
280
  end.flatten
114
281
  elsif values.one? && values.first.is_a?(Array)
@@ -128,30 +295,50 @@ class Redis
128
295
  cmd 'TS.MADD', args
129
296
  end
130
297
 
131
- def range(range, count: nil, agg: nil)
132
- if range.is_a? Hash
133
- args = range.fetch(:from), range.fetch(:to)
134
- elsif range.is_a? Range
135
- args = range.min, range.max
136
- end
137
- args.map! { |ts| (ts.to_f * 1000).to_i }
138
- args.append('COUNT', count) if count
139
- args.append('AGGREGATION', agg) if agg
140
- cmd('TS.RANGE', key, args).map do |ts, val|
141
- 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)
142
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) }
143
324
  end
144
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
145
332
  def retention=(val)
146
- @retention = val.to_i
333
+ # TODO: this should also accept an ActiveSupport::Duration
147
334
  cmd 'TS.ALTER', key, 'RETENTION', val.to_i
148
335
  end
149
336
 
150
- private
151
-
152
- def cmd(name, *args)
153
- puts "DEBUG: #{name} #{args.join(' ')}" if ENV['DEBUG']
154
- 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
155
342
  end
156
343
  end
157
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