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 +4 -4
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +1 -1
- data/README.md +73 -4
- data/lib/ext/time_msec.rb +8 -0
- data/lib/redis-time-series.rb +5 -3
- data/lib/redis/time_series.rb +35 -2
- data/lib/redis/time_series/aggregation.rb +53 -0
- data/lib/redis/time_series/errors.rb +15 -0
- data/lib/redis/time_series/{filter.rb → filters.rb} +59 -8
- metadata +6 -4
- data/lib/time/msec.rb +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 58671cce77efea0c1b4f405e60dcd73cb3f01101fabdb0cb9bc8b11335392116
|
4
|
+
data.tar.gz: b9c7e56687b5eb7d5286a88990ca20c5d82f6857070ab953e6d74925d5cbefb6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 402de8f815ef0ff2dfe9426d6be2e68508093c3a35d7def4721f5bfe9ba3a9b52d8b375600302b989c24fc10b4d9c7bc43249a81715267112fa4776d8429ea18
|
7
|
+
data.tar.gz: 3c6a554173d5f9c4a92a77c65c8c8fed8035395b3066ff0cbf222e1983347645fbabf1e498516218a042c3a3c0ad7d157ab5e39b1e22971a73189a5e3aba9496
|
data/CHANGELOG.md
CHANGED
@@ -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
|
|
data/Gemfile.lock
CHANGED
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.
|
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.
|
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
|
data/lib/redis-time-series.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
1
|
require 'bigdecimal'
|
2
2
|
require 'forwardable'
|
3
|
-
require '
|
4
|
-
require 'redis/time_series/
|
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.
|
12
|
+
VERSION = '0.4.0'
|
11
13
|
end
|
data/lib/redis/time_series.rb
CHANGED
@@ -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
|
37
|
-
filters =
|
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
|
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}
|
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 =
|
92
|
-
|
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.
|
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-
|
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/
|
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:
|