redis-time-series 0.1.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,9 +1,13 @@
1
1
  require 'bigdecimal'
2
- require 'time/msec'
3
- require 'redis'
4
- 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'
5
10
  require 'redis/time_series/sample'
11
+ require 'redis/time_series'
6
12
 
7
- class RedisTimeSeries
8
- VERSION = '0.1.0'
9
- 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 #{label_string}" if labels.any?
65
- cmd 'TS.CREATE', args
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', label_string
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,34 +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
155
- end
156
-
157
- def label_string
158
- labels.map { |label, value| "#{label} #{value}" }.join(' ')
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
159
342
  end
160
343
  end
161
344
  end