redis-time-series 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d699daec01bb891952501de6a51cc4b966be74a0f8585030bafb4f6a908b36e
4
- data.tar.gz: dfa027d344c4d5485b0a22a33edd67973c2a60cb60dbbaa1a57101ccce8833fc
3
+ metadata.gz: 58671cce77efea0c1b4f405e60dcd73cb3f01101fabdb0cb9bc8b11335392116
4
+ data.tar.gz: b9c7e56687b5eb7d5286a88990ca20c5d82f6857070ab953e6d74925d5cbefb6
5
5
  SHA512:
6
- metadata.gz: acadcad9c6919e810f0e100bfe20b60055507e2efa98d0853fca6c15a252f99b65c722c71acc99d37d8d9f72964d91f862c9604fb96458ca3faaddce9f841752
7
- data.tar.gz: 489415e8247d8cffefcf13e4e70c82cde752170c4416bc0973495c5d38b1d27d2b554cdd62db8729c9c71b125cb4848aa5a1efd81b998aa5f6e5683c13be8ccc
6
+ metadata.gz: 402de8f815ef0ff2dfe9426d6be2e68508093c3a35d7def4721f5bfe9ba3a9b52d8b375600302b989c24fc10b4d9c7bc43249a81715267112fa4776d8429ea18
7
+ data.tar.gz: 3c6a554173d5f9c4a92a77c65c8c8fed8035395b3066ff0cbf222e1983347645fbabf1e498516218a042c3a3c0ad7d157ab5e39b1e22971a73189a5e3aba9496
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0
6
+ * Added [hash-based filter DSL](https://github.com/dzunk/redis-time-series/tree/7173c73588da50614c02f9c89bf2ecef77766a78#filter-dsl)
7
+ * Removed `Time#ts_msec` monkey-patch
8
+ * Renamed `TimeSeries.queryindex` to `.query_index`
9
+ * Added `TS.CREATERULE` and `TS.DELETERULE` commands
10
+ * Renamed `InvalidFilters` to `FilterError`
11
+
5
12
  ## 0.3.0
6
13
  * Added `TS.QUERYINDEX` command
7
14
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-time-series (0.3.0)
4
+ redis-time-series (0.4.0)
5
5
  redis (~> 4.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -162,23 +162,92 @@ ts.size
162
162
  ```
163
163
  Find series matching specific label(s)
164
164
  ```ruby
165
- Redis::TimeSeries.queryindex('foo=bar')
165
+ Redis::TimeSeries.query_index('foo=bar')
166
166
  => [#<Redis::TimeSeries:0x00007fc115ba1610
167
167
  @key="ts3",
168
168
  @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
169
169
  @retention=nil,
170
170
  @uncompressed=false>]
171
171
  # Note that you need at least one "label equals value" filter
172
- Redis::TimeSeries.queryindex('foo!=bar')
172
+ Redis::TimeSeries.query_index('foo!=bar')
173
173
  => RuntimeError: Filtering requires at least one equality comparison
174
+ # query_index is also aliased as .where for fluency
175
+ Redis::TimeSeries.where('foo=bar')
176
+ => [#<Redis::TimeSeries:0x00007fb8981010c8
177
+ @key="ts3",
178
+ @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
179
+ @retention=nil,
180
+ @uncompressed=false>]
181
+ ```
182
+ ### Filter DSL
183
+ You can provide filter strings directly, per the time series documentation.
184
+ ```ruby
185
+ Redis::TimeSeries.where('foo=bar')
186
+ => [#<Redis::TimeSeries:0x00007fb8981010c8...>]
187
+ ```
188
+ There is also a hash-based syntax available, which may be more pleasant to work with.
189
+ ```ruby
190
+ Redis::TimeSeries.where(foo: 'bar')
191
+ => [#<Redis::TimeSeries:0x00007fb89811dca0...>]
192
+ ```
193
+ All six filter types are represented in hash format below.
194
+ ```ruby
195
+ {
196
+ foo: 'bar', # label=value (equality)
197
+ foo: { not: 'bar' }, # label!=value (inequality)
198
+ foo: true, # label= (presence)
199
+ foo: false, # label!= (absence)
200
+ foo: [1, 2], # label=(1,2) (any value)
201
+ foo: { not: [1, 2] } # label!=(1,2) (no values)
202
+ }
203
+ ```
204
+ Note the special use of `true` and `false`. If you're representing a boolean value with a label, rather than setting its value to "true" or "false" (which would be treated as strings in Redis anyway), you should add or remove the label from the series.
205
+
206
+ Values can be any object that responds to `.to_s`:
207
+ ```ruby
208
+ class Person
209
+ def initialize(name)
210
+ @name = name
211
+ end
212
+
213
+ def to_s
214
+ @name
215
+ end
216
+ end
217
+
218
+ Redis::TimeSeries.where(person: Person.new('John'))
219
+ #=> TS.QUERYINDEX person=John
220
+ ```
221
+
222
+ ### Compaction Rules
223
+ Add a compaction rule to a series.
224
+ ```ruby
225
+ # `dest` needs to be created before the rule is added.
226
+ other_ts = Redis::TimeSeries.create('other_ts')
227
+
228
+ # Note: aggregation durations are measured in milliseconds
229
+ ts.create_rule(dest: other_ts, aggregation: [:count, 6000])
230
+
231
+ # Can also provide a string key instead of a time series object
232
+ ts.create_rule(dest: 'other_ts', aggregation: [:avg, 120000])
233
+
234
+ # Can also provide an Aggregation object instead of an array
235
+ agg = Redis::TimeSeries::Aggregation.new(:avg, 120000)
236
+ ts.create_rule(dest: other_ts, aggregation: agg)
237
+
238
+ # Class-level method also available
239
+ Redis::TimeSeries.create_rule(source: ts, dest: other_ts, aggregation: ['std.p', 150000])
240
+ ```
241
+ Remove an existing compaction rule
242
+ ```ruby
243
+ ts.delete_rule(dest: 'other_ts')
244
+ Redis::TimeSeries.delete_rule(source: ts, dest: 'other_ts')
174
245
  ```
175
246
 
176
247
 
177
248
  ### TODO
178
249
  * `TS.REVRANGE`
179
250
  * `TS.MRANGE`/`TS.MREVRANGE`
180
- * Compaction rules
181
- * Filters
182
251
  * Probably a bunch more stuff
183
252
 
184
253
  ## Development
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ module TimeMsec
3
+ refine Time do
4
+ def ts_msec
5
+ (to_f * 1000.0).to_i
6
+ end
7
+ end
8
+ end
@@ -1,11 +1,13 @@
1
1
  require 'bigdecimal'
2
2
  require 'forwardable'
3
- require 'time/msec'
4
- require 'redis/time_series/filter'
3
+ require 'ext/time_msec'
4
+ require 'redis/time_series/errors'
5
+ require 'redis/time_series/aggregation'
6
+ require 'redis/time_series/filters'
5
7
  require 'redis/time_series/info'
6
8
  require 'redis/time_series/sample'
7
9
  require 'redis/time_series'
8
10
 
9
11
  class RedisTimeSeries
10
- VERSION = '0.3.0'
12
+ VERSION = '0.4.0'
11
13
  end
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ using TimeMsec
3
+
2
4
  class Redis
3
5
  class TimeSeries
4
6
  extend Forwardable
@@ -8,6 +10,27 @@ class Redis
8
10
  new(key, **options).create(labels: options[:labels])
9
11
  end
10
12
 
13
+ def create_rule(source:, dest:, aggregation:)
14
+ args = [
15
+ source.is_a?(self) ? source.key : source.to_s,
16
+ dest.is_a?(self) ? dest.key : dest.to_s,
17
+ Aggregation.parse(aggregation).to_a
18
+ ]
19
+ redis.call 'TS.CREATERULE', *args.flatten
20
+ end
21
+
22
+ def delete_rule(source:, dest:)
23
+ args = [
24
+ source.is_a?(self) ? source.key : source.to_s,
25
+ dest.is_a?(self) ? dest.key : dest.to_s
26
+ ]
27
+ redis.call 'TS.DELETERULE', *args
28
+ end
29
+
30
+ def destroy(key)
31
+ redis.del key
32
+ end
33
+
11
34
  def madd(data)
12
35
  data.reduce([]) do |memo, (key, value)|
13
36
  if value.is_a?(Hash) || (value.is_a?(Array) && value.first.is_a?(Array))
@@ -33,11 +56,13 @@ class Redis
33
56
  end
34
57
  end
35
58
 
36
- def queryindex(filter_value)
37
- filters = Filter.new(filter_value)
59
+ def query_index(filter_value)
60
+ filters = Filters.new(filter_value)
38
61
  filters.validate!
62
+ puts "DEBUG: TS.QUERYINDEX #{filters.to_a.join(' ')}" if ENV['DEBUG']
39
63
  redis.call('TS.QUERYINDEX', *filters.to_a).map { |key| new(key) }
40
64
  end
65
+ alias where query_index
41
66
 
42
67
  def redis
43
68
  @redis ||= Redis.current
@@ -72,6 +97,14 @@ class Redis
72
97
  self
73
98
  end
74
99
 
100
+ def create_rule(dest:, aggregation:)
101
+ self.class.create_rule(source: self, dest: dest, aggregation: aggregation)
102
+ end
103
+
104
+ def delete_rule(dest:)
105
+ self.class.delete_rule(source: self, dest: dest)
106
+ end
107
+
75
108
  def decrby(value = 1, timestamp = nil)
76
109
  args = [key, value]
77
110
  args << timestamp if timestamp
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ class Redis
3
+ class TimeSeries
4
+ class Aggregation
5
+ TYPES = %w[
6
+ avg
7
+ count
8
+ first
9
+ last
10
+ max
11
+ min
12
+ range
13
+ std.p
14
+ std.s
15
+ sum
16
+ var.p
17
+ var.s
18
+ ]
19
+
20
+ attr_reader :type, :duration
21
+
22
+ alias aggregation_type type
23
+ alias time_bucket duration
24
+
25
+ def self.parse(agg)
26
+ return agg if agg.is_a?(self)
27
+ return new(agg.first, agg.last) if agg.is_a?(Array) && agg.size == 2
28
+ raise AggregationError, "Couldn't parse #{agg} into an aggregation rule!"
29
+ end
30
+
31
+ def initialize(type, duration)
32
+ unless TYPES.include? type.to_s
33
+ raise AggregationError, "#{type} is not a valid aggregation type!"
34
+ end
35
+ @type = type.to_s
36
+ @duration = duration.to_i
37
+ end
38
+
39
+ def to_a
40
+ ['AGGREGATION', type, duration]
41
+ end
42
+
43
+ def to_s
44
+ to_a.join(' ')
45
+ end
46
+
47
+ def ==(other)
48
+ parsed = self.class.parse(other)
49
+ type == parsed.type && duration == parsed.duration
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,15 @@
1
+ class Redis
2
+ class TimeSeries
3
+ # Base error class for convenient `rescue`ing
4
+ class Error < StandardError; end
5
+
6
+ # Invalid filter error is raised when attempting to filter without at least
7
+ # one equality comparison ("foo=bar")
8
+ class FilterError < Error; end
9
+
10
+ # Aggregation error is raised when attempting to create anaggreation with
11
+ # an unknown type, or when calling a command with an invalid aggregation value.
12
+ # @see Redis::TimeSeries::Aggregation
13
+ class AggregationError < Error; end
14
+ end
15
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  class Redis
3
3
  class TimeSeries
4
- class Filter
4
+ class Filters
5
5
  Equal = Struct.new(:label, :value) do
6
6
  self::REGEX = /^[^!]+=[^(]+/
7
7
 
@@ -9,6 +9,10 @@ class Redis
9
9
  new(*str.split('='))
10
10
  end
11
11
 
12
+ def to_h
13
+ { label => value }
14
+ end
15
+
12
16
  def to_s
13
17
  "#{label}=#{value}"
14
18
  end
@@ -21,6 +25,10 @@ class Redis
21
25
  new(*str.split('!='))
22
26
  end
23
27
 
28
+ def to_h
29
+ { label => { not: value } }
30
+ end
31
+
24
32
  def to_s
25
33
  "#{label}!=#{value}"
26
34
  end
@@ -33,6 +41,10 @@ class Redis
33
41
  new(str.delete('='))
34
42
  end
35
43
 
44
+ def to_h
45
+ { label => false }
46
+ end
47
+
36
48
  def to_s
37
49
  "#{label}="
38
50
  end
@@ -45,6 +57,10 @@ class Redis
45
57
  new(str.delete('!='))
46
58
  end
47
59
 
60
+ def to_h
61
+ { label => true }
62
+ end
63
+
48
64
  def to_s
49
65
  "#{label}!="
50
66
  end
@@ -59,8 +75,12 @@ class Redis
59
75
  new(label, values)
60
76
  end
61
77
 
78
+ def to_h
79
+ { label => values }
80
+ end
81
+
62
82
  def to_s
63
- "#{label}=(#{values.join(',')})"
83
+ "#{label}=(#{values.map(&:to_s).join(',')})"
64
84
  end
65
85
  end
66
86
 
@@ -73,14 +93,18 @@ class Redis
73
93
  new(label, values)
74
94
  end
75
95
 
96
+ def to_h
97
+ { label => { not: values } }
98
+ end
99
+
76
100
  def to_s
77
- "#{label}!=(#{values.join(',')})"
101
+ "#{label}!=(#{values.map(&:to_s).join(',')})"
78
102
  end
79
103
  end
80
104
 
81
105
  TYPES = [Equal, NotEqual, Absent, Present, AnyValue, NoValues]
82
106
  TYPES.each do |type|
83
- define_method "#{type.to_s.split('::').last.gsub(/(.)([A-Z])/,'\1_\2').downcase}_filters" do
107
+ define_method "#{type.to_s.split('::').last.gsub(/(.)([A-Z])/,'\1_\2').downcase}" do
84
108
  filters.select { |f| f.is_a? type }
85
109
  end
86
110
  end
@@ -88,12 +112,15 @@ class Redis
88
112
  attr_reader :filters
89
113
 
90
114
  def initialize(filters = nil)
91
- filters = parse_string(filters) if filters.is_a?(String)
92
- @filters = filters.presence || {}
115
+ @filters = case filters
116
+ when String then parse_string(filters)
117
+ when Hash then parse_hash(filters)
118
+ else []
119
+ end
93
120
  end
94
121
 
95
122
  def validate!
96
- valid? || raise('Filtering requires at least one equality comparison')
123
+ valid? || raise(FilterError, 'Filtering requires at least one equality comparison')
97
124
  end
98
125
 
99
126
  def valid?
@@ -104,15 +131,39 @@ class Redis
104
131
  filters.map(&:to_s)
105
132
  end
106
133
 
134
+ def to_h
135
+ filters.reduce({}) { |h, filter| h.merge(filter.to_h) }
136
+ end
137
+
138
+ def to_s
139
+ to_a.join(' ')
140
+ end
141
+
107
142
  private
108
143
 
109
144
  def parse_string(filter_string)
145
+ return unless filter_string.is_a? String
110
146
  filter_string.split(' ').map do |str|
111
147
  match = TYPES.find { |f| f::REGEX.match? str }
112
- raise "Unable to parse '#{str}'" unless match
148
+ raise(FilterError, "Unable to parse '#{str}'") unless match
113
149
  match.parse(str)
114
150
  end
115
151
  end
152
+
153
+ def parse_hash(filter_hash)
154
+ return unless filter_hash.is_a? Hash
155
+ filter_hash.map do |label, value|
156
+ case value
157
+ when TrueClass then Present.new(label)
158
+ when FalseClass then Absent.new(label)
159
+ when Array then AnyValue.new(label, value)
160
+ when Hash
161
+ raise(FilterError, "Invalid filter hash value #{value}") unless value.keys === [:not]
162
+ (v = value.values.first).is_a?(Array) ? NoValues.new(label, v) : NotEqual.new(label, v)
163
+ else Equal.new(label, value)
164
+ end
165
+ end
166
+ end
116
167
  end
117
168
  end
118
169
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-time-series
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Duszynski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-17 00:00:00.000000000 Z
11
+ date: 2020-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -112,12 +112,14 @@ files:
112
112
  - Rakefile
113
113
  - bin/console
114
114
  - bin/setup
115
+ - lib/ext/time_msec.rb
115
116
  - lib/redis-time-series.rb
116
117
  - lib/redis/time_series.rb
117
- - lib/redis/time_series/filter.rb
118
+ - lib/redis/time_series/aggregation.rb
119
+ - lib/redis/time_series/errors.rb
120
+ - lib/redis/time_series/filters.rb
118
121
  - lib/redis/time_series/info.rb
119
122
  - lib/redis/time_series/sample.rb
120
- - lib/time/msec.rb
121
123
  - redis-time-series.gemspec
122
124
  homepage: https://github.com/dzunk/redis-time-series
123
125
  licenses:
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
- class Time
3
- # TODO: use refinemenets instead of monkey-patching Time
4
- def ts_msec
5
- (to_f * 1000.0).to_i
6
- end
7
- end