redis-time-series 0.3.0 → 0.6.0
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.
- checksums.yaml +4 -4
- data/.github/workflows/rspec.yml +25 -4
- data/.gitignore +1 -0
- data/Appraisals +7 -0
- data/CHANGELOG.md +27 -0
- data/README.md +140 -16
- data/bin/setup +2 -1
- data/lib/ext/time_msec.rb +29 -0
- data/lib/redis-time-series.rb +9 -5
- data/lib/redis/time_series.rb +281 -68
- data/lib/redis/time_series/aggregation.rb +88 -0
- data/lib/redis/time_series/client.rb +77 -0
- data/lib/redis/time_series/duplicate_policy.rb +49 -0
- data/lib/redis/time_series/errors.rb +24 -0
- data/lib/redis/time_series/{filter.rb → filters.rb} +59 -8
- data/lib/redis/time_series/info.rb +96 -14
- data/lib/redis/time_series/rule.rb +49 -0
- data/lib/redis/time_series/sample.rb +22 -6
- data/lib/redis/time_series/version.rb +6 -0
- data/redis-time-series.gemspec +7 -5
- metadata +57 -17
- data/Gemfile.lock +0 -58
- 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: 4648678522434687a6e278490c7a308c64f6460394b8ebde16b2ce2daa9e92ad
|
4
|
+
data.tar.gz: e2f48fb1c8efebb3b4d6e4c47d1d86137a10ac8a54735c94b3bdb35ef6396651
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 269a787d8d2a2a467a52e915ab717bc5b20eb28cacc55eb5dfc9ce3909a7d83cdd4e15b19afc92612d19fa551c6ba793df76ceef03d8bb8bdb16b6dc0253d2cc
|
7
|
+
data.tar.gz: c5d845052ca8f2a518fb3a20a82a8d8aa35128cb824f54d2531a949767e90f8c7979ede07ba1bd56ae30a825edb9f6be90e52ca404a14199bfd13484026d8d40
|
data/.github/workflows/rspec.yml
CHANGED
@@ -11,18 +11,39 @@ on:
|
|
11
11
|
jobs:
|
12
12
|
spec:
|
13
13
|
runs-on: ubuntu-latest
|
14
|
+
strategy:
|
15
|
+
matrix:
|
16
|
+
image_version: ['latest', 'edge']
|
17
|
+
ruby_version: ['2.6', '2.7']
|
14
18
|
services:
|
15
19
|
redis:
|
16
|
-
image: redislabs/redistimeseries
|
20
|
+
image: redislabs/redistimeseries:${{ matrix.image_version }}
|
17
21
|
ports:
|
18
22
|
- 6379:6379/tcp
|
23
|
+
env:
|
24
|
+
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
|
25
|
+
GIT_COMMIT_SHA: ${{ github.sha }}
|
26
|
+
GIT_BRANCH: ${{ github.head_ref }}
|
19
27
|
steps:
|
20
28
|
- uses: actions/checkout@v2
|
21
29
|
- name: Set up Ruby
|
22
30
|
uses: ruby/setup-ruby@v1
|
23
31
|
with:
|
24
|
-
ruby-version:
|
32
|
+
ruby-version: ${{ matrix.ruby_version }}
|
33
|
+
- name: Set up CodeClimate
|
34
|
+
run: |
|
35
|
+
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
|
36
|
+
chmod +x ./cc-test-reporter
|
37
|
+
./cc-test-reporter before-build
|
25
38
|
- name: Install dependencies
|
26
|
-
run:
|
39
|
+
run: |
|
40
|
+
bundle install
|
41
|
+
bundle exec appraisal install
|
27
42
|
- name: Run specs
|
28
|
-
run: bundle exec rake spec
|
43
|
+
run: bundle exec appraisal rake spec
|
44
|
+
- name: Upload coverage report
|
45
|
+
run: ./cc-test-reporter after-build -t simplecov coverage/.resultset.json
|
46
|
+
- uses: actions/upload-artifact@v2
|
47
|
+
with:
|
48
|
+
name: coverage
|
49
|
+
path: coverage/
|
data/.gitignore
CHANGED
data/Appraisals
ADDED
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,33 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 0.6.0
|
6
|
+
* Add CHUNK_SIZE param to CREATE, ADD, INCRBY, DECRBY commands (#53)
|
7
|
+
* Add duplication policy to TS.CREATE and TS.ADD commands (#51)
|
8
|
+
* Add support for endless ranges to TS.RANGE (#50)
|
9
|
+
* Cast label values to integers in Info struct (#49)
|
10
|
+
* Build against edge upstream in addition to latest stable (#48)
|
11
|
+
|
12
|
+
## 0.5.2
|
13
|
+
* Add chunk_type to info struct (#47)
|
14
|
+
|
15
|
+
## 0.5.1
|
16
|
+
* Update Info struct for RTS 1.4 compatibility (#45)
|
17
|
+
|
18
|
+
## 0.5.0
|
19
|
+
* Fix aggregations for TS.RANGE command (#34)
|
20
|
+
* Extract client handling into Client module (#32)
|
21
|
+
* Add `uncompressed` param to TS.ADD, TS.INCRBY, TS.DECRBY (#35)
|
22
|
+
* Add `Redis::TimeSeries::Rule` object (#38)
|
23
|
+
* Add [YARD documentation](https://rubydoc.info/gems/redis-time-series) (#40)
|
24
|
+
|
25
|
+
## 0.4.0
|
26
|
+
* Added [hash-based filter DSL](https://github.com/dzunk/redis-time-series/tree/7173c73588da50614c02f9c89bf2ecef77766a78#filter-dsl)
|
27
|
+
* Removed `Time#ts_msec` monkey-patch
|
28
|
+
* Renamed `TimeSeries.queryindex` to `.query_index`
|
29
|
+
* Added `TS.CREATERULE` and `TS.DELETERULE` commands
|
30
|
+
* Renamed `InvalidFilters` to `FilterError`
|
31
|
+
|
5
32
|
## 0.3.0
|
6
33
|
* Added `TS.QUERYINDEX` command
|
7
34
|
|
data/README.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
[](https://github.com/dzunk/redis-time-series/actions?query=workflow%3ARSpec+branch%3Amaster)
|
2
2
|
[](https://badge.fury.io/rb/redis-time-series)
|
3
|
+
[](https://rubydoc.info/gems/redis-time-series)
|
4
|
+
[](https://codeclimate.com/github/dzunk/redis-time-series/maintainability)
|
5
|
+
[](https://codeclimate.com/github/dzunk/redis-time-series/test_coverage)
|
3
6
|
|
4
7
|
# RedisTimeSeries
|
5
8
|
|
@@ -73,6 +76,10 @@ Add a single value with a timestamp
|
|
73
76
|
```ruby
|
74
77
|
ts.add 1234, 3.minutes.ago # Used ActiveSupport here, but any Time object works fine
|
75
78
|
=> #<Redis::TimeSeries::Sample:0x00007fa6ce05f3f8 @time=2020-06-25 23:39:54 -0700, @value=0.1234e4>
|
79
|
+
|
80
|
+
# Optionally store data uncompressed
|
81
|
+
ts.add 5678, uncompressed: true
|
82
|
+
=> #<Redis::TimeSeries::Sample:0x00007f93f43cdf68 @time=2020-07-18 23:15:29 -0700, @value=0.5678e4>
|
76
83
|
```
|
77
84
|
Add multiple values with timestamps
|
78
85
|
```ruby
|
@@ -89,6 +96,10 @@ ts.increment # alias of incrby
|
|
89
96
|
=> 1593154255069
|
90
97
|
ts.decrement # alias of decrby
|
91
98
|
=> 1593154257344
|
99
|
+
|
100
|
+
# Optionally store data uncompressed
|
101
|
+
ts.incrby 4, uncompressed: true
|
102
|
+
=> 1595139299769
|
92
103
|
```
|
93
104
|
```ruby
|
94
105
|
ts.get
|
@@ -120,31 +131,53 @@ ts.get
|
|
120
131
|
```
|
121
132
|
Get a range of values
|
122
133
|
```ruby
|
123
|
-
|
134
|
+
# Time range as an argument
|
135
|
+
ts.range(10.minutes.ago..Time.current)
|
124
136
|
=> [#<Redis::TimeSeries::Sample:0x00007fa25f13fc28 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
137
|
+
#<Redis::TimeSeries::Sample:0x00007fa25f13db58 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
|
138
|
+
#<Redis::TimeSeries::Sample:0x00007fa25f13d900 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
|
139
|
+
#<Redis::TimeSeries::Sample:0x00007fa25f13d680 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]
|
140
|
+
|
141
|
+
# Time range as keyword args
|
142
|
+
ts.range(from: 10.minutes.ago, to: Time.current)
|
143
|
+
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
|
144
|
+
#<Redis::TimeSeries::Sample:0x00007fa25dc01d20 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
|
145
|
+
#<Redis::TimeSeries::Sample:0x00007fa25dc01b68 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
|
146
|
+
#<Redis::TimeSeries::Sample:0x00007fa25dc019b0 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]
|
147
|
+
|
148
|
+
# Limit number of results with count argument
|
149
|
+
ts.range(10.minutes.ago..Time.current, count: 2)
|
129
150
|
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
|
130
|
-
|
131
|
-
|
132
|
-
|
151
|
+
#<Redis::TimeSeries::Sample:0x00007fa25dc01d20 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>]
|
152
|
+
|
153
|
+
# Apply aggregations to the range
|
154
|
+
ts.range(from: 10.minutes.ago, to: Time.current, aggregation: [:avg, 10.minutes])
|
155
|
+
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:00 -0700, @value=0.575e2>]
|
133
156
|
```
|
134
157
|
Get info about the series
|
135
158
|
```ruby
|
136
159
|
ts.info
|
137
160
|
=> #<struct Redis::TimeSeries::Info
|
161
|
+
series=
|
162
|
+
#<Redis::TimeSeries:0x00007ff46da9b578 @key="ts3", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>,
|
138
163
|
total_samples=3,
|
139
|
-
memory_usage=
|
140
|
-
first_timestamp=
|
141
|
-
last_timestamp=
|
164
|
+
memory_usage=4264,
|
165
|
+
first_timestamp=1595187993605,
|
166
|
+
last_timestamp=1595187993629,
|
142
167
|
retention_time=0,
|
143
168
|
chunk_count=1,
|
144
169
|
max_samples_per_chunk=256,
|
145
170
|
labels={"foo"=>"bar"},
|
146
171
|
source_key=nil,
|
147
|
-
rules=
|
172
|
+
rules=
|
173
|
+
[#<Redis::TimeSeries::Rule:0x00007ff46db30c68
|
174
|
+
@aggregation=#<Redis::TimeSeries::Aggregation:0x00007ff46db30c18 @duration=3600000, @type="avg">,
|
175
|
+
@destination_key="ts1",
|
176
|
+
@source=
|
177
|
+
#<Redis::TimeSeries:0x00007ff46da9b578
|
178
|
+
@key="ts3",
|
179
|
+
@redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>>]>
|
180
|
+
|
148
181
|
# Each info property is also a method on the time series object
|
149
182
|
ts.memory_usage
|
150
183
|
=> 4208
|
@@ -152,6 +185,7 @@ ts.labels
|
|
152
185
|
=> {"foo"=>"bar"}
|
153
186
|
ts.total_samples
|
154
187
|
=> 3
|
188
|
+
|
155
189
|
# Total samples also available as #count, #length, and #size
|
156
190
|
ts.count
|
157
191
|
=> 3
|
@@ -162,23 +196,113 @@ ts.size
|
|
162
196
|
```
|
163
197
|
Find series matching specific label(s)
|
164
198
|
```ruby
|
165
|
-
Redis::TimeSeries.
|
199
|
+
Redis::TimeSeries.query_index('foo=bar')
|
166
200
|
=> [#<Redis::TimeSeries:0x00007fc115ba1610
|
167
201
|
@key="ts3",
|
168
202
|
@redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
|
169
203
|
@retention=nil,
|
170
204
|
@uncompressed=false>]
|
171
205
|
# Note that you need at least one "label equals value" filter
|
172
|
-
Redis::TimeSeries.
|
206
|
+
Redis::TimeSeries.query_index('foo!=bar')
|
173
207
|
=> RuntimeError: Filtering requires at least one equality comparison
|
208
|
+
# query_index is also aliased as .where for fluency
|
209
|
+
Redis::TimeSeries.where('foo=bar')
|
210
|
+
=> [#<Redis::TimeSeries:0x00007fb8981010c8
|
211
|
+
@key="ts3",
|
212
|
+
@redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
|
213
|
+
@retention=nil,
|
214
|
+
@uncompressed=false>]
|
215
|
+
```
|
216
|
+
### Filter DSL
|
217
|
+
You can provide filter strings directly, per the time series documentation.
|
218
|
+
```ruby
|
219
|
+
Redis::TimeSeries.where('foo=bar')
|
220
|
+
=> [#<Redis::TimeSeries:0x00007fb8981010c8...>]
|
221
|
+
```
|
222
|
+
There is also a hash-based syntax available, which may be more pleasant to work with.
|
223
|
+
```ruby
|
224
|
+
Redis::TimeSeries.where(foo: 'bar')
|
225
|
+
=> [#<Redis::TimeSeries:0x00007fb89811dca0...>]
|
226
|
+
```
|
227
|
+
All six filter types are represented in hash format below.
|
228
|
+
```ruby
|
229
|
+
{
|
230
|
+
foo: 'bar', # label=value (equality)
|
231
|
+
foo: { not: 'bar' }, # label!=value (inequality)
|
232
|
+
foo: true, # label= (presence)
|
233
|
+
foo: false, # label!= (absence)
|
234
|
+
foo: [1, 2], # label=(1,2) (any value)
|
235
|
+
foo: { not: [1, 2] } # label!=(1,2) (no values)
|
236
|
+
}
|
237
|
+
```
|
238
|
+
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.
|
239
|
+
|
240
|
+
Values can be any object that responds to `.to_s`:
|
241
|
+
```ruby
|
242
|
+
class Person
|
243
|
+
def initialize(name)
|
244
|
+
@name = name
|
245
|
+
end
|
246
|
+
|
247
|
+
def to_s
|
248
|
+
@name
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
Redis::TimeSeries.where(person: Person.new('John'))
|
253
|
+
#=> TS.QUERYINDEX person=John
|
254
|
+
```
|
255
|
+
|
256
|
+
### Compaction Rules
|
257
|
+
Add a compaction rule to a series.
|
258
|
+
```ruby
|
259
|
+
# Destintation time series needs to be created before the rule is added.
|
260
|
+
other_ts = Redis::TimeSeries.create('other_ts')
|
261
|
+
|
262
|
+
# Aggregation buckets are measured in milliseconds
|
263
|
+
ts.create_rule(dest: other_ts, aggregation: [:count, 60000]) # 1 minute
|
264
|
+
|
265
|
+
# Can provide a string key instead of a time series object
|
266
|
+
ts.create_rule(dest: 'other_ts', aggregation: [:avg, 120000])
|
267
|
+
|
268
|
+
# If you're using Rails or ActiveSupport, you can provide an
|
269
|
+
# ActiveSupport::Duration instead of an integer
|
270
|
+
ts.create_rule(dest: other_ts, aggregation: [:avg, 2.minutes])
|
271
|
+
|
272
|
+
# Can also provide an Aggregation object instead of an array
|
273
|
+
agg = Redis::TimeSeries::Aggregation.new(:avg, 120000)
|
274
|
+
ts.create_rule(dest: other_ts, aggregation: agg)
|
275
|
+
|
276
|
+
# Class-level method also available
|
277
|
+
Redis::TimeSeries.create_rule(source: ts, dest: other_ts, aggregation: ['std.p', 150000])
|
278
|
+
```
|
279
|
+
Get existing compaction rules
|
280
|
+
```ruby
|
281
|
+
ts.rules
|
282
|
+
=> [#<Redis::TimeSeries::Rule:0x00007ff46e91c728
|
283
|
+
@aggregation=#<Redis::TimeSeries::Aggregation:0x00007ff46e91c6d8 @duration=3600000, @type="avg">,
|
284
|
+
@destination_key="ts1",
|
285
|
+
@source=
|
286
|
+
#<Redis::TimeSeries:0x00007ff46da9b578 @key="ts3", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>>]
|
287
|
+
|
288
|
+
# Get properties of a rule too
|
289
|
+
ts.rules.first.aggregation
|
290
|
+
=> #<Redis::TimeSeries::Aggregation:0x00007ff46d146d38 @duration=3600000, @type="avg">
|
291
|
+
ts.rules.first.destination
|
292
|
+
=> #<Redis::TimeSeries:0x00007ff46d8a3d60 @key="ts1", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>
|
293
|
+
```
|
294
|
+
|
295
|
+
Remove an existing compaction rule
|
296
|
+
```ruby
|
297
|
+
ts.delete_rule(dest: 'other_ts')
|
298
|
+
ts.rules.first.delete
|
299
|
+
Redis::TimeSeries.delete_rule(source: ts, dest: 'other_ts')
|
174
300
|
```
|
175
301
|
|
176
302
|
|
177
303
|
### TODO
|
178
304
|
* `TS.REVRANGE`
|
179
305
|
* `TS.MRANGE`/`TS.MREVRANGE`
|
180
|
-
* Compaction rules
|
181
|
-
* Filters
|
182
306
|
* Probably a bunch more stuff
|
183
307
|
|
184
308
|
## Development
|
data/bin/setup
CHANGED
@@ -3,10 +3,12 @@
|
|
3
3
|
system 'bundle install'
|
4
4
|
system 'docker pull redislabs/redistimeseries:latest'
|
5
5
|
container_id = `docker run -p 6379:6379 -dit --rm redislabs/redistimeseries`
|
6
|
+
at_exit { system "docker stop #{container_id}" }
|
6
7
|
|
7
8
|
require 'bundler/setup'
|
8
9
|
require 'active_support/core_ext/numeric/time'
|
9
10
|
require 'redis'
|
11
|
+
require 'redis-time-series'
|
10
12
|
|
11
13
|
Redis.current.flushall
|
12
14
|
ts1 = Redis::TimeSeries.create('ts1')
|
@@ -26,5 +28,4 @@ ts3.incrby 2
|
|
26
28
|
sleep 0.01
|
27
29
|
ts3.decrement
|
28
30
|
|
29
|
-
at_exit { system "docker stop #{container_id}" }
|
30
31
|
system "docker logs -f #{container_id}"
|
@@ -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
|
data/lib/redis-time-series.rb
CHANGED
@@ -1,11 +1,15 @@
|
|
1
1
|
require 'bigdecimal'
|
2
2
|
require 'forwardable'
|
3
|
-
require '
|
4
|
-
|
3
|
+
require 'ext/time_msec'
|
4
|
+
|
5
|
+
require 'redis/time_series/client'
|
6
|
+
require 'redis/time_series/errors'
|
7
|
+
require 'redis/time_series/aggregation'
|
8
|
+
require 'redis/time_series/duplicate_policy'
|
9
|
+
require 'redis/time_series/filters'
|
10
|
+
require 'redis/time_series/rule'
|
5
11
|
require 'redis/time_series/info'
|
6
12
|
require 'redis/time_series/sample'
|
7
13
|
require 'redis/time_series'
|
8
14
|
|
9
|
-
class RedisTimeSeries
|
10
|
-
VERSION = '0.3.0'
|
11
|
-
end
|
15
|
+
class RedisTimeSeries; end
|
data/lib/redis/time_series.rb
CHANGED
@@ -1,88 +1,257 @@
|
|
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
|
+
# @option options [String, Symbol] :duplicate_policy
|
41
|
+
# A duplication policy to resolve conflicts when adding values to the series.
|
42
|
+
# Valid values are in Redis::TimeSeries::DuplicatePolicy::VALID_POLICIES
|
43
|
+
# @option options [Integer] :chunk_size
|
44
|
+
# Amount of memory, in bytes, to allocate for each chunk of data
|
45
|
+
#
|
46
|
+
# @return [Redis::TimeSeries] the created time series
|
47
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tscreate
|
7
48
|
def create(key, **options)
|
8
|
-
new(key,
|
49
|
+
new(key, redis: options.fetch(:redis, redis)).create(**options)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Create a compaction rule for a series. Note that both source and destination series
|
53
|
+
# must exist before the rule can be created.
|
54
|
+
#
|
55
|
+
# @param source [String, TimeSeries] the source series (or key) to apply the rule to
|
56
|
+
# @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
|
57
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
58
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
59
|
+
# aggregation_type and duration +[:avg, 120000]+
|
60
|
+
#
|
61
|
+
# @return [String] the string "OK"
|
62
|
+
# @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
|
63
|
+
# @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
|
64
|
+
#
|
65
|
+
# @see TimeSeries#create_rule
|
66
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tscreaterule
|
67
|
+
def create_rule(source:, dest:, aggregation:)
|
68
|
+
cmd 'TS.CREATERULE', key_for(source), key_for(dest), Aggregation.parse(aggregation).to_a
|
69
|
+
end
|
70
|
+
|
71
|
+
# Delete an existing compaction rule.
|
72
|
+
#
|
73
|
+
# @param source [String, TimeSeries] the source series (or key) to remove the rule from
|
74
|
+
# @param dest [String, TimeSeries] the destination series (or key) the rule applies to
|
75
|
+
#
|
76
|
+
# @return [String] the string "OK"
|
77
|
+
# @raise [Redis::CommandError] if the compaction rule does not exist
|
78
|
+
def delete_rule(source:, dest:)
|
79
|
+
cmd 'TS.DELETERULE', key_for(source), key_for(dest)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Delete all data and remove a time series from Redis.
|
83
|
+
#
|
84
|
+
# @param key [String] the key to remove
|
85
|
+
# @return [1] if the series existed
|
86
|
+
# @return [0] if the series did not exist
|
87
|
+
def destroy(key)
|
88
|
+
redis.del key
|
9
89
|
end
|
10
90
|
|
11
91
|
def madd(data)
|
12
92
|
data.reduce([]) do |memo, (key, value)|
|
13
|
-
|
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
|
93
|
+
memo << parse_madd_values(key, value)
|
27
94
|
memo
|
28
95
|
end.then do |args|
|
29
|
-
|
30
|
-
redis.call('TS.MADD', args.flatten).each_with_index.map do |result, idx|
|
96
|
+
cmd('TS.MADD', args).each_with_index.map do |result, idx|
|
31
97
|
result.is_a?(Redis::CommandError) ? result : Sample.new(result, args[idx][2])
|
32
98
|
end
|
33
99
|
end
|
34
100
|
end
|
35
101
|
|
36
|
-
|
37
|
-
|
102
|
+
# Search for a time series matching the provided filters. Refer to the {Filters} documentation
|
103
|
+
# for more details on how to filter.
|
104
|
+
#
|
105
|
+
# @example Using a filter string
|
106
|
+
# Redis::TimeSeries.query_index('foo=bar')
|
107
|
+
# #=> [#<Redis::TimeSeries:0x00007ff00e222788 @key="ts3", @redis=#<Redis...>>]
|
108
|
+
# @example Using the .where alias with hash DSL
|
109
|
+
# Redis::TimeSeries.where(foo: 'bar')
|
110
|
+
# #=> [#<Redis::TimeSeries:0x00007ff00e2a1d30 @key="ts3", @redis=#<Redis...>>]
|
111
|
+
#
|
112
|
+
# @param filter_value [Hash, String] a set of filters to query with
|
113
|
+
# @return [Array<TimeSeries>] an array of series that matched the given filters
|
114
|
+
#
|
115
|
+
# @see Filters
|
116
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsqueryindex
|
117
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#filtering
|
118
|
+
def query_index(filter_value)
|
119
|
+
filters = Filters.new(filter_value)
|
38
120
|
filters.validate!
|
39
|
-
|
121
|
+
cmd('TS.QUERYINDEX', filters.to_a).map { |key| new(key) }
|
40
122
|
end
|
123
|
+
alias where query_index
|
124
|
+
|
125
|
+
private
|
41
126
|
|
42
|
-
def
|
43
|
-
|
127
|
+
def key_for(series_or_string)
|
128
|
+
series_or_string.is_a?(self) ? series_or_string.key : series_or_string.to_s
|
44
129
|
end
|
45
130
|
|
46
|
-
def
|
47
|
-
|
131
|
+
def parse_madd_values(key, raw)
|
132
|
+
if raw.is_a?(Hash) || (raw.is_a?(Array) && raw.first.is_a?(Array))
|
133
|
+
# multiple timestamp => value pairs
|
134
|
+
raw.map do |timestamp, value|
|
135
|
+
[key, timestamp, value]
|
136
|
+
end
|
137
|
+
elsif raw.is_a? Array
|
138
|
+
# single [timestamp, value]
|
139
|
+
[key, raw.first, raw.last]
|
140
|
+
else
|
141
|
+
# single value, no timestamp
|
142
|
+
[key, '*', raw]
|
143
|
+
end
|
48
144
|
end
|
49
145
|
end
|
50
146
|
|
51
|
-
|
147
|
+
# @return [String] the Redis key this time series is stored in
|
148
|
+
attr_reader :key
|
52
149
|
|
53
|
-
|
150
|
+
# @param key [String] the Redis key to store the time series in
|
151
|
+
# @param redis [Redis] an optional Redis client
|
152
|
+
def initialize(key, redis: self.class.redis)
|
54
153
|
@key = key
|
55
|
-
@redis =
|
56
|
-
@retention = options[:retention]
|
57
|
-
@uncompressed = options[:uncompressed] || false
|
154
|
+
@redis = redis
|
58
155
|
end
|
59
156
|
|
60
|
-
|
61
|
-
|
62
|
-
|
157
|
+
# Add a value to the series.
|
158
|
+
#
|
159
|
+
# @param value [Numeric] the value to add
|
160
|
+
# @param timestamp [Time, Numeric] the +Time+, or integer timestamp in milliseconds, to add the value
|
161
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
162
|
+
# @param on_duplicate [String, Symbol] a duplication policy for conflict resolution
|
163
|
+
# @param chunk_size [Integer] set default chunk size, in bytes, for the time series
|
164
|
+
#
|
165
|
+
# @return [Sample] the value that was added
|
166
|
+
# @raise [Redis::CommandError] if the value being added is older than the latest timestamp in the series
|
167
|
+
#
|
168
|
+
# @see TimeSeries::DuplicatePolicy
|
169
|
+
def add(value, timestamp = '*', uncompressed: nil, on_duplicate: nil, chunk_size: nil)
|
170
|
+
ts = cmd 'TS.ADD',
|
171
|
+
key,
|
172
|
+
timestamp,
|
173
|
+
value,
|
174
|
+
('UNCOMPRESSED' if uncompressed),
|
175
|
+
(['CHUNK_SIZE', chunk_size] if chunk_size),
|
176
|
+
(DuplicatePolicy.new(on_duplicate).to_a('ON_DUPLICATE') if on_duplicate)
|
63
177
|
Sample.new(ts, value)
|
64
178
|
end
|
65
179
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
180
|
+
# Issues a TS.CREATE command for the current series.
|
181
|
+
# You should use class method {Redis::TimeSeries.create} instead.
|
182
|
+
# @api private
|
183
|
+
def create(retention: nil, uncompressed: nil, labels: nil, duplicate_policy: nil, chunk_size: nil)
|
184
|
+
cmd 'TS.CREATE', key,
|
185
|
+
(['RETENTION', retention] if retention),
|
186
|
+
('UNCOMPRESSED' if uncompressed),
|
187
|
+
(['CHUNK_SIZE', chunk_size] if chunk_size),
|
188
|
+
(DuplicatePolicy.new(duplicate_policy).to_a if duplicate_policy),
|
189
|
+
(['LABELS', labels.to_a] if labels&.any?)
|
72
190
|
self
|
73
191
|
end
|
74
192
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
193
|
+
# Create a compaction rule for this series.
|
194
|
+
#
|
195
|
+
# @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
|
196
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
197
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
198
|
+
# aggregation_type and duration +[:avg, 120000]+
|
199
|
+
#
|
200
|
+
# @return [String] the string "OK"
|
201
|
+
# @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
|
202
|
+
# @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
|
203
|
+
#
|
204
|
+
# @see TimeSeries.create_rule
|
205
|
+
def create_rule(dest:, aggregation:)
|
206
|
+
self.class.create_rule(source: self, dest: dest, aggregation: aggregation)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Delete an existing compaction rule.
|
210
|
+
#
|
211
|
+
# @param dest [String, TimeSeries] the destination series (or key) the rule applies to
|
212
|
+
#
|
213
|
+
# @return [String] the string "OK"
|
214
|
+
# @raise [Redis::CommandError] if the compaction rule does not exist
|
215
|
+
#
|
216
|
+
# @see TimeSeries.delete_rule
|
217
|
+
def delete_rule(dest:)
|
218
|
+
self.class.delete_rule(source: self, dest: dest)
|
219
|
+
end
|
220
|
+
|
221
|
+
# Decrement the current value of the series.
|
222
|
+
#
|
223
|
+
# @param value [Integer] the amount to decrement by
|
224
|
+
# @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
|
225
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
226
|
+
# @param chunk_size [Integer] set default chunk size, in bytes, for the time series
|
227
|
+
#
|
228
|
+
# @return [Integer] the timestamp the value was stored at
|
229
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
|
230
|
+
def decrby(value = 1, timestamp = nil, uncompressed: nil, chunk_size: nil)
|
231
|
+
cmd 'TS.DECRBY',
|
232
|
+
key,
|
233
|
+
value,
|
234
|
+
(timestamp if timestamp),
|
235
|
+
('UNCOMPRESSED' if uncompressed),
|
236
|
+
(['CHUNK_SIZE', chunk_size] if chunk_size)
|
79
237
|
end
|
80
238
|
alias decrement decrby
|
81
239
|
|
240
|
+
|
241
|
+
# Delete all data and remove this time series from Redis.
|
242
|
+
#
|
243
|
+
# @return [1] if the series existed
|
244
|
+
# @return [0] if the series did not exist
|
82
245
|
def destroy
|
83
246
|
redis.del key
|
84
247
|
end
|
85
248
|
|
249
|
+
# Get the most recent sample for this series.
|
250
|
+
#
|
251
|
+
# @return [Sample] the most recent sample for this series
|
252
|
+
# @return [nil] if there are no samples in the series
|
253
|
+
#
|
254
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsget
|
86
255
|
def get
|
87
256
|
cmd('TS.GET', key).then do |timestamp, value|
|
88
257
|
return unless value
|
@@ -90,28 +259,52 @@ class Redis
|
|
90
259
|
end
|
91
260
|
end
|
92
261
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
262
|
+
# Increment the current value of the series.
|
263
|
+
#
|
264
|
+
# @param value [Integer] the amount to increment by
|
265
|
+
# @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
|
266
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
267
|
+
# @param chunk_size [Integer] set default chunk size, in bytes, for the time series
|
268
|
+
#
|
269
|
+
# @return [Integer] the timestamp the value was stored at
|
270
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
|
271
|
+
def incrby(value = 1, timestamp = nil, uncompressed: nil, chunk_size: nil)
|
272
|
+
cmd 'TS.INCRBY',
|
273
|
+
key,
|
274
|
+
value,
|
275
|
+
(timestamp if timestamp),
|
276
|
+
('UNCOMPRESSED' if uncompressed),
|
277
|
+
(['CHUNK_SIZE', chunk_size] if chunk_size)
|
97
278
|
end
|
98
279
|
alias increment incrby
|
99
280
|
|
281
|
+
# Get information about the series.
|
282
|
+
# Note that all properties of {Info} are also available on the series itself
|
283
|
+
# via delegation.
|
284
|
+
#
|
285
|
+
# @return [Info] an info object about the current series
|
286
|
+
#
|
287
|
+
# @see Info
|
288
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsinfo
|
100
289
|
def info
|
101
|
-
cmd('TS.INFO', key)
|
290
|
+
Info.parse series: self, data: cmd('TS.INFO', key)
|
102
291
|
end
|
103
|
-
def_delegators :info, *Info.members
|
104
|
-
%i[count length size].each { |m| def_delegator :info, :total_samples, m }
|
292
|
+
def_delegators :info, *Info.members - [:series] + %i[count length size source]
|
105
293
|
|
294
|
+
# Assign labels to the series using +TS.ALTER+
|
295
|
+
#
|
296
|
+
# @param val [Hash] a hash of label-value pairs
|
297
|
+
# @return [Hash] the assigned labels
|
298
|
+
#
|
299
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
|
106
300
|
def labels=(val)
|
107
|
-
cmd 'TS.ALTER', key, 'LABELS', val.to_a
|
301
|
+
cmd 'TS.ALTER', key, 'LABELS', val.to_a
|
108
302
|
end
|
109
303
|
|
110
304
|
def madd(*values)
|
111
305
|
if values.one? && values.first.is_a?(Hash)
|
112
306
|
# Hash of timestamp => value pairs
|
113
307
|
args = values.first.map do |ts, val|
|
114
|
-
ts = ts.ts_msec if ts.is_a? Time
|
115
308
|
[key, ts, val]
|
116
309
|
end.flatten
|
117
310
|
elsif values.one? && values.first.is_a?(Array)
|
@@ -131,30 +324,50 @@ class Redis
|
|
131
324
|
cmd 'TS.MADD', args
|
132
325
|
end
|
133
326
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
327
|
+
# Get a range of values from the series
|
328
|
+
#
|
329
|
+
# @param range [Hash, Range] a time range, or hash of +from+ and +to+ values
|
330
|
+
# @param count [Integer] the maximum number of results to return
|
331
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
332
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
333
|
+
# aggregation_type and duration +[:avg, 120000]+
|
334
|
+
#
|
335
|
+
# @return [Array<Sample>] an array of samples matching the range query
|
336
|
+
#
|
337
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsrangetsrevrange
|
338
|
+
def range(range, count: nil, aggregation: nil)
|
339
|
+
if range.is_a?(Hash)
|
340
|
+
# This is to support from: and to: passed in as hash keys
|
341
|
+
# `range` will swallow all parameters if they're all hash syntax
|
342
|
+
count = range.delete(:count)
|
343
|
+
aggregation = range.delete(:aggregation)
|
344
|
+
range = range.fetch(:from)..range[:to]
|
145
345
|
end
|
346
|
+
cmd('TS.RANGE',
|
347
|
+
key,
|
348
|
+
(range.begin || '-'),
|
349
|
+
(range.end || '+'),
|
350
|
+
(['COUNT', count] if count),
|
351
|
+
Aggregation.parse(aggregation)&.to_a
|
352
|
+
).map { |ts, val| Sample.new(ts, val) }
|
146
353
|
end
|
147
354
|
|
355
|
+
# Set data retention time for the series using +TS.ALTER+
|
356
|
+
#
|
357
|
+
# @param val [Integer] the number of milliseconds data should be retained. +0+ means retain forever.
|
358
|
+
# @return [Integer] the retention value of the series
|
359
|
+
#
|
360
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
|
148
361
|
def retention=(val)
|
149
|
-
|
362
|
+
# TODO: this should also accept an ActiveSupport::Duration
|
150
363
|
cmd 'TS.ALTER', key, 'RETENTION', val.to_i
|
151
364
|
end
|
152
365
|
|
153
|
-
|
154
|
-
|
155
|
-
def
|
156
|
-
|
157
|
-
|
366
|
+
# Compare series based on Redis key and configured client.
|
367
|
+
# @return [Boolean] whether the two TimeSeries objects refer to the same series
|
368
|
+
def ==(other)
|
369
|
+
return false unless other.is_a?(self.class)
|
370
|
+
key == other.key && redis == other.redis
|
158
371
|
end
|
159
372
|
end
|
160
373
|
end
|