redis-time-series 0.3.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![RSpec](https://github.com/dzunk/redis-time-series/workflows/RSpec/badge.svg)](https://github.com/dzunk/redis-time-series/actions?query=workflow%3ARSpec+branch%3Amaster)
|
2
2
|
[![Gem Version](https://badge.fury.io/rb/redis-time-series.svg)](https://badge.fury.io/rb/redis-time-series)
|
3
|
+
[![Documentation](https://img.shields.io/badge/docs-rubydoc.info-brightgreen)](https://rubydoc.info/gems/redis-time-series)
|
4
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/19a5925c20318508b4a4/maintainability)](https://codeclimate.com/github/dzunk/redis-time-series/maintainability)
|
5
|
+
[![Test Coverage](https://api.codeclimate.com/v1/badges/19a5925c20318508b4a4/test_coverage)](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
|