redis-time-series 0.4.0 → 0.5.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 +15 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +9 -1
- data/README.md +71 -16
- data/bin/setup +2 -1
- data/lib/ext/time_msec.rb +21 -0
- data/lib/redis-time-series.rb +3 -3
- data/lib/redis/time_series.rb +229 -78
- data/lib/redis/time_series/aggregation.rb +40 -5
- data/lib/redis/time_series/client.rb +77 -0
- data/lib/redis/time_series/errors.rb +9 -5
- data/lib/redis/time_series/info.rb +58 -8
- 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 +4 -3
- metadata +20 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 60ddc8ba631c4016031490a9eb8c3e8659d6b38d3f21e3798a253ec3f63b2815
|
4
|
+
data.tar.gz: 7993209c75f9f23ed0fae6f918020f2a7a793575e6636e57c1bc225db3f3a63d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1bd033ed2dfef155ba923ebee94089e41f507dcb8fcb63711a56585703d1603c32ff86b99067641b8a248587925d2bf489affd79822118e5fb42b05dbcf1c374
|
7
|
+
data.tar.gz: 553ffbea629efff4b1b436faf7542f35204cd9a0f47c2cf28dcd3ecc0261bd399eb19ac57a56985fc3fbeaef0e4b2e24786483bc385e06928fedcbad4f6709d0
|
data/.github/workflows/rspec.yml
CHANGED
@@ -16,13 +16,28 @@ jobs:
|
|
16
16
|
image: redislabs/redistimeseries:latest
|
17
17
|
ports:
|
18
18
|
- 6379:6379/tcp
|
19
|
+
env:
|
20
|
+
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
|
21
|
+
GIT_COMMIT_SHA: ${{ github.sha }}
|
22
|
+
GIT_BRANCH: ${{ github.head_ref }}
|
19
23
|
steps:
|
20
24
|
- uses: actions/checkout@v2
|
21
25
|
- name: Set up Ruby
|
22
26
|
uses: ruby/setup-ruby@v1
|
23
27
|
with:
|
24
28
|
ruby-version: 2.6
|
29
|
+
- name: Set up CodeClimate
|
30
|
+
run: |
|
31
|
+
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
|
32
|
+
chmod +x ./cc-test-reporter
|
33
|
+
./cc-test-reporter before-build
|
25
34
|
- name: Install dependencies
|
26
35
|
run: bundle install
|
27
36
|
- name: Run specs
|
28
37
|
run: bundle exec rake spec
|
38
|
+
- name: Upload coverage report
|
39
|
+
run: ./cc-test-reporter after-build -t simplecov coverage/.resultset.json
|
40
|
+
- uses: actions/upload-artifact@v2
|
41
|
+
with:
|
42
|
+
name: coverage
|
43
|
+
path: coverage/
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,13 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
## 0.5.0
|
6
|
+
* Fix aggregations for TS.RANGE command (#34)
|
7
|
+
* Extract client handling into Client module (#32)
|
8
|
+
* Add `uncompressed` param to TS.ADD, TS.INCRBY, TS.DECRBY (#35)
|
9
|
+
* Add `Redis::TimeSeries::Rule` object (#38)
|
10
|
+
* Add [YARD documentation](https://rubydoc.info/gems/redis-time-series) (#40)
|
11
|
+
|
5
12
|
## 0.4.0
|
6
13
|
* Added [hash-based filter DSL](https://github.com/dzunk/redis-time-series/tree/7173c73588da50614c02f9c89bf2ecef77766a78#filter-dsl)
|
7
14
|
* Removed `Time#ts_msec` monkey-patch
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
redis-time-series (0.
|
4
|
+
redis-time-series (0.5.0)
|
5
5
|
redis (~> 4.0)
|
6
6
|
|
7
7
|
GEM
|
@@ -16,8 +16,10 @@ GEM
|
|
16
16
|
coderay (1.1.3)
|
17
17
|
concurrent-ruby (1.1.6)
|
18
18
|
diff-lcs (1.3)
|
19
|
+
docile (1.3.2)
|
19
20
|
i18n (1.8.3)
|
20
21
|
concurrent-ruby (~> 1.0)
|
22
|
+
json (2.3.1)
|
21
23
|
method_source (1.0.0)
|
22
24
|
minitest (5.14.1)
|
23
25
|
pry (0.13.1)
|
@@ -38,6 +40,11 @@ GEM
|
|
38
40
|
diff-lcs (>= 1.2.0, < 2.0)
|
39
41
|
rspec-support (~> 3.9.0)
|
40
42
|
rspec-support (3.9.3)
|
43
|
+
simplecov (0.17.1)
|
44
|
+
docile (~> 1.1)
|
45
|
+
json (>= 1.8, < 3)
|
46
|
+
simplecov-html (~> 0.10.0)
|
47
|
+
simplecov-html (0.10.2)
|
41
48
|
thread_safe (0.3.6)
|
42
49
|
tzinfo (1.2.7)
|
43
50
|
thread_safe (~> 0.1)
|
@@ -53,6 +60,7 @@ DEPENDENCIES
|
|
53
60
|
rake (~> 13.0)
|
54
61
|
redis-time-series!
|
55
62
|
rspec (~> 3.0)
|
63
|
+
simplecov (< 0.18)
|
56
64
|
|
57
65
|
BUNDLED WITH
|
58
66
|
1.17.2
|
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)
|
129
143
|
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
|
130
|
-
|
131
|
-
|
132
|
-
|
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)
|
150
|
+
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
|
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
|
@@ -222,15 +256,19 @@ Redis::TimeSeries.where(person: Person.new('John'))
|
|
222
256
|
### Compaction Rules
|
223
257
|
Add a compaction rule to a series.
|
224
258
|
```ruby
|
225
|
-
#
|
259
|
+
# Destintation time series needs to be created before the rule is added.
|
226
260
|
other_ts = Redis::TimeSeries.create('other_ts')
|
227
261
|
|
228
|
-
#
|
229
|
-
ts.create_rule(dest: other_ts, aggregation: [:count,
|
262
|
+
# Aggregation buckets are measured in milliseconds
|
263
|
+
ts.create_rule(dest: other_ts, aggregation: [:count, 60000]) # 1 minute
|
230
264
|
|
231
|
-
# Can
|
265
|
+
# Can provide a string key instead of a time series object
|
232
266
|
ts.create_rule(dest: 'other_ts', aggregation: [:avg, 120000])
|
233
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
|
+
|
234
272
|
# Can also provide an Aggregation object instead of an array
|
235
273
|
agg = Redis::TimeSeries::Aggregation.new(:avg, 120000)
|
236
274
|
ts.create_rule(dest: other_ts, aggregation: agg)
|
@@ -238,9 +276,26 @@ ts.create_rule(dest: other_ts, aggregation: agg)
|
|
238
276
|
# Class-level method also available
|
239
277
|
Redis::TimeSeries.create_rule(source: ts, dest: other_ts, aggregation: ['std.p', 150000])
|
240
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
|
+
|
241
295
|
Remove an existing compaction rule
|
242
296
|
```ruby
|
243
297
|
ts.delete_rule(dest: 'other_ts')
|
298
|
+
ts.rules.first.delete
|
244
299
|
Redis::TimeSeries.delete_rule(source: ts, dest: 'other_ts')
|
245
300
|
```
|
246
301
|
|
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}"
|
data/lib/ext/time_msec.rb
CHANGED
@@ -1,8 +1,29 @@
|
|
1
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
|
2
16
|
module TimeMsec
|
3
17
|
refine Time do
|
18
|
+
# TODO: convert to #to_msec
|
4
19
|
def ts_msec
|
5
20
|
(to_f * 1000.0).to_i
|
6
21
|
end
|
7
22
|
end
|
23
|
+
|
24
|
+
refine Time.singleton_class do
|
25
|
+
def from_msec(timestamp)
|
26
|
+
at(timestamp / 1000.0)
|
27
|
+
end
|
28
|
+
end
|
8
29
|
end
|
data/lib/redis-time-series.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
require 'bigdecimal'
|
2
2
|
require 'forwardable'
|
3
3
|
require 'ext/time_msec'
|
4
|
+
require 'redis/time_series/client'
|
4
5
|
require 'redis/time_series/errors'
|
5
6
|
require 'redis/time_series/aggregation'
|
6
7
|
require 'redis/time_series/filters'
|
8
|
+
require 'redis/time_series/rule'
|
7
9
|
require 'redis/time_series/info'
|
8
10
|
require 'redis/time_series/sample'
|
9
11
|
require 'redis/time_series'
|
10
12
|
|
11
|
-
class RedisTimeSeries
|
12
|
-
VERSION = '0.4.0'
|
13
|
-
end
|
13
|
+
class RedisTimeSeries; end
|
data/lib/redis/time_series.rb
CHANGED
@@ -2,120 +2,233 @@
|
|
2
2
|
using TimeMsec
|
3
3
|
|
4
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>
|
5
24
|
class TimeSeries
|
25
|
+
extend Client
|
6
26
|
extend Forwardable
|
7
27
|
|
8
28
|
class << self
|
29
|
+
# Create a new time series.
|
30
|
+
#
|
31
|
+
# @param key [String] the Redis key to store time series data in
|
32
|
+
# @option options [Hash] :labels
|
33
|
+
# A hash of label-value pairs to apply to this series.
|
34
|
+
# @option options [Redis] :redis (self.class.redis) a different Redis client to use
|
35
|
+
# @option options [Integer] :retention
|
36
|
+
# Maximum age for samples compared to last event time (in milliseconds).
|
37
|
+
# With no value, the series will not be trimmed.
|
38
|
+
# @option options [Boolean] :uncompressed
|
39
|
+
# When true, series data will be stored in an uncompressed format.
|
40
|
+
#
|
41
|
+
# @return [Redis::TimeSeries] the created time series
|
42
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tscreate
|
9
43
|
def create(key, **options)
|
10
|
-
new(key,
|
44
|
+
new(key, redis: options.fetch(:redis, redis)).create(**options)
|
11
45
|
end
|
12
46
|
|
47
|
+
# Create a compaction rule for a series. Note that both source and destination series
|
48
|
+
# must exist before the rule can be created.
|
49
|
+
#
|
50
|
+
# @param source [String, TimeSeries] the source series (or key) to apply the rule to
|
51
|
+
# @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
|
52
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
53
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
54
|
+
# aggregation_type and duration +[:avg, 120000]+
|
55
|
+
#
|
56
|
+
# @return [String] the string "OK"
|
57
|
+
# @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
|
58
|
+
# @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
|
59
|
+
#
|
60
|
+
# @see TimeSeries#create_rule
|
61
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tscreaterule
|
13
62
|
def create_rule(source:, dest:, aggregation:)
|
14
|
-
|
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
|
63
|
+
cmd 'TS.CREATERULE', key_for(source), key_for(dest), Aggregation.parse(aggregation).to_a
|
20
64
|
end
|
21
65
|
|
66
|
+
# Delete an existing compaction rule.
|
67
|
+
#
|
68
|
+
# @param source [String, TimeSeries] the source series (or key) to remove the rule from
|
69
|
+
# @param dest [String, TimeSeries] the destination series (or key) the rule applies to
|
70
|
+
#
|
71
|
+
# @return [String] the string "OK"
|
72
|
+
# @raise [Redis::CommandError] if the compaction rule does not exist
|
22
73
|
def delete_rule(source:, dest:)
|
23
|
-
|
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
|
74
|
+
cmd 'TS.DELETERULE', key_for(source), key_for(dest)
|
28
75
|
end
|
29
76
|
|
77
|
+
# Delete all data and remove a time series from Redis.
|
78
|
+
#
|
79
|
+
# @param key [String] the key to remove
|
80
|
+
# @return [1] if the series existed
|
81
|
+
# @return [0] if the series did not exist
|
30
82
|
def destroy(key)
|
31
83
|
redis.del key
|
32
84
|
end
|
33
85
|
|
34
86
|
def madd(data)
|
35
87
|
data.reduce([]) do |memo, (key, value)|
|
36
|
-
|
37
|
-
# multiple timestamp => value pairs
|
38
|
-
value.each do |timestamp, nested_value|
|
39
|
-
timestamp = timestamp.ts_msec if timestamp.is_a? Time
|
40
|
-
memo << [key, timestamp, nested_value]
|
41
|
-
end
|
42
|
-
elsif value.is_a? Array
|
43
|
-
# single [timestamp, value]
|
44
|
-
key = key.ts_msec if key.is_a? Time
|
45
|
-
memo << [key, value]
|
46
|
-
else
|
47
|
-
# single value, no timestamp
|
48
|
-
memo << [key, '*', value]
|
49
|
-
end
|
88
|
+
memo << parse_madd_values(key, value)
|
50
89
|
memo
|
51
90
|
end.then do |args|
|
52
|
-
|
53
|
-
redis.call('TS.MADD', args.flatten).each_with_index.map do |result, idx|
|
91
|
+
cmd('TS.MADD', args).each_with_index.map do |result, idx|
|
54
92
|
result.is_a?(Redis::CommandError) ? result : Sample.new(result, args[idx][2])
|
55
93
|
end
|
56
94
|
end
|
57
95
|
end
|
58
96
|
|
97
|
+
# Search for a time series matching the provided filters. Refer to the {Filters} documentation
|
98
|
+
# for more details on how to filter.
|
99
|
+
#
|
100
|
+
# @example Using a filter string
|
101
|
+
# Redis::TimeSeries.query_index('foo=bar')
|
102
|
+
# #=> [#<Redis::TimeSeries:0x00007ff00e222788 @key="ts3", @redis=#<Redis...>>]
|
103
|
+
# @example Using the .where alias with hash DSL
|
104
|
+
# Redis::TimeSeries.where(foo: 'bar')
|
105
|
+
# #=> [#<Redis::TimeSeries:0x00007ff00e2a1d30 @key="ts3", @redis=#<Redis...>>]
|
106
|
+
#
|
107
|
+
# @param filter_value [Hash, String] a set of filters to query with
|
108
|
+
# @return [Array<TimeSeries>] an array of series that matched the given filters
|
109
|
+
#
|
110
|
+
# @see Filters
|
111
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsqueryindex
|
112
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#filtering
|
59
113
|
def query_index(filter_value)
|
60
114
|
filters = Filters.new(filter_value)
|
61
115
|
filters.validate!
|
62
|
-
|
63
|
-
redis.call('TS.QUERYINDEX', *filters.to_a).map { |key| new(key) }
|
116
|
+
cmd('TS.QUERYINDEX', filters.to_a).map { |key| new(key) }
|
64
117
|
end
|
65
118
|
alias where query_index
|
66
119
|
|
67
|
-
|
68
|
-
|
120
|
+
private
|
121
|
+
|
122
|
+
def key_for(series_or_string)
|
123
|
+
series_or_string.is_a?(self) ? series_or_string.key : series_or_string.to_s
|
69
124
|
end
|
70
125
|
|
71
|
-
def
|
72
|
-
|
126
|
+
def parse_madd_values(key, raw)
|
127
|
+
if raw.is_a?(Hash) || (raw.is_a?(Array) && raw.first.is_a?(Array))
|
128
|
+
# multiple timestamp => value pairs
|
129
|
+
raw.map do |timestamp, value|
|
130
|
+
[key, timestamp, value]
|
131
|
+
end
|
132
|
+
elsif raw.is_a? Array
|
133
|
+
# single [timestamp, value]
|
134
|
+
[key, raw.first, raw.last]
|
135
|
+
else
|
136
|
+
# single value, no timestamp
|
137
|
+
[key, '*', raw]
|
138
|
+
end
|
73
139
|
end
|
74
140
|
end
|
75
141
|
|
76
|
-
|
142
|
+
# @return [String] the Redis key this time series is stored in
|
143
|
+
attr_reader :key
|
77
144
|
|
78
|
-
|
145
|
+
# @param key [String] the Redis key to store the time series in
|
146
|
+
# @param redis [Redis] an optional Redis client
|
147
|
+
def initialize(key, redis: self.class.redis)
|
79
148
|
@key = key
|
80
|
-
@redis =
|
81
|
-
@retention = options[:retention]
|
82
|
-
@uncompressed = options[:uncompressed] || false
|
149
|
+
@redis = redis
|
83
150
|
end
|
84
151
|
|
85
|
-
|
86
|
-
|
87
|
-
|
152
|
+
# Add a value to the series.
|
153
|
+
#
|
154
|
+
# @param value [Numeric] the value to add
|
155
|
+
# @param timestamp [Time, Numeric] the +Time+, or integer timestamp in milliseconds, to add the value
|
156
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
157
|
+
#
|
158
|
+
# @return [Sample] the value that was added
|
159
|
+
# @raise [Redis::CommandError] if the value being added is older than the latest timestamp in the series
|
160
|
+
def add(value, timestamp = '*', uncompressed: nil)
|
161
|
+
ts = cmd 'TS.ADD', key, timestamp, value, ('UNCOMPRESSED' if uncompressed)
|
88
162
|
Sample.new(ts, value)
|
89
163
|
end
|
90
164
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
165
|
+
# Issues a TS.CREATE command for the current series.
|
166
|
+
# You should use class method {Redis::TimeSeries.create} instead.
|
167
|
+
# @api private
|
168
|
+
def create(retention: nil, uncompressed: nil, labels: nil)
|
169
|
+
cmd 'TS.CREATE', key,
|
170
|
+
(['RETENTION', retention] if retention),
|
171
|
+
('UNCOMPRESSED' if uncompressed),
|
172
|
+
(['LABELS', labels.to_a] if labels&.any?)
|
97
173
|
self
|
98
174
|
end
|
99
175
|
|
176
|
+
# Create a compaction rule for this series.
|
177
|
+
#
|
178
|
+
# @param dest [String, TimeSeries] the destination series (or key) to aggregate the data
|
179
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
180
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
181
|
+
# aggregation_type and duration +[:avg, 120000]+
|
182
|
+
#
|
183
|
+
# @return [String] the string "OK"
|
184
|
+
# @raise [Redis::TimeSeries::AggregationError] if the given aggregation params are invalid
|
185
|
+
# @raise [Redis::CommandError] if the compaction rule cannot be applied to either series
|
186
|
+
#
|
187
|
+
# @see TimeSeries.create_rule
|
100
188
|
def create_rule(dest:, aggregation:)
|
101
189
|
self.class.create_rule(source: self, dest: dest, aggregation: aggregation)
|
102
190
|
end
|
103
191
|
|
192
|
+
# Delete an existing compaction rule.
|
193
|
+
#
|
194
|
+
# @param dest [String, TimeSeries] the destination series (or key) the rule applies to
|
195
|
+
#
|
196
|
+
# @return [String] the string "OK"
|
197
|
+
# @raise [Redis::CommandError] if the compaction rule does not exist
|
198
|
+
#
|
199
|
+
# @see TimeSeries.delete_rule
|
104
200
|
def delete_rule(dest:)
|
105
201
|
self.class.delete_rule(source: self, dest: dest)
|
106
202
|
end
|
107
203
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
204
|
+
# Decrement the current value of the series.
|
205
|
+
#
|
206
|
+
# @param value [Integer] the amount to decrement by
|
207
|
+
# @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
|
208
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
209
|
+
#
|
210
|
+
# @return [Integer] the timestamp the value was stored at
|
211
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
|
212
|
+
def decrby(value = 1, timestamp = nil, uncompressed: nil)
|
213
|
+
cmd 'TS.DECRBY', key, value, (timestamp if timestamp), ('UNCOMPRESSED' if uncompressed)
|
112
214
|
end
|
113
215
|
alias decrement decrby
|
114
216
|
|
217
|
+
|
218
|
+
# Delete all data and remove this time series from Redis.
|
219
|
+
#
|
220
|
+
# @return [1] if the series existed
|
221
|
+
# @return [0] if the series did not exist
|
115
222
|
def destroy
|
116
223
|
redis.del key
|
117
224
|
end
|
118
225
|
|
226
|
+
# Get the most recent sample for this series.
|
227
|
+
#
|
228
|
+
# @return [Sample] the most recent sample for this series
|
229
|
+
# @return [nil] if there are no samples in the series
|
230
|
+
#
|
231
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsget
|
119
232
|
def get
|
120
233
|
cmd('TS.GET', key).then do |timestamp, value|
|
121
234
|
return unless value
|
@@ -123,28 +236,46 @@ class Redis
|
|
123
236
|
end
|
124
237
|
end
|
125
238
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
239
|
+
# Increment the current value of the series.
|
240
|
+
#
|
241
|
+
# @param value [Integer] the amount to increment by
|
242
|
+
# @param timestamp [Time, Integer] the Time or integer millisecond timestamp to save the new value at
|
243
|
+
# @param uncompressed [Boolean] if true, stores data in an uncompressed format
|
244
|
+
#
|
245
|
+
# @return [Integer] the timestamp the value was stored at
|
246
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsincrbytsdecrby
|
247
|
+
def incrby(value = 1, timestamp = nil, uncompressed: nil)
|
248
|
+
cmd 'TS.INCRBY', key, value, (timestamp if timestamp), ('UNCOMPRESSED' if uncompressed)
|
130
249
|
end
|
131
250
|
alias increment incrby
|
132
251
|
|
252
|
+
# Get information about the series.
|
253
|
+
# Note that all properties of {Info} are also available on the series itself
|
254
|
+
# via delegation.
|
255
|
+
#
|
256
|
+
# @return [Info] an info object about the current series
|
257
|
+
#
|
258
|
+
# @see Info
|
259
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsinfo
|
133
260
|
def info
|
134
|
-
cmd('TS.INFO', key)
|
261
|
+
Info.parse series: self, data: cmd('TS.INFO', key)
|
135
262
|
end
|
136
|
-
def_delegators :info, *Info.members
|
137
|
-
%i[count length size].each { |m| def_delegator :info, :total_samples, m }
|
263
|
+
def_delegators :info, *Info.members - [:series] + %i[count length size source]
|
138
264
|
|
265
|
+
# Assign labels to the series using +TS.ALTER+
|
266
|
+
#
|
267
|
+
# @param val [Hash] a hash of label-value pairs
|
268
|
+
# @return [Hash] the assigned labels
|
269
|
+
#
|
270
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
|
139
271
|
def labels=(val)
|
140
|
-
cmd 'TS.ALTER', key, 'LABELS', val.to_a
|
272
|
+
cmd 'TS.ALTER', key, 'LABELS', val.to_a
|
141
273
|
end
|
142
274
|
|
143
275
|
def madd(*values)
|
144
276
|
if values.one? && values.first.is_a?(Hash)
|
145
277
|
# Hash of timestamp => value pairs
|
146
278
|
args = values.first.map do |ts, val|
|
147
|
-
ts = ts.ts_msec if ts.is_a? Time
|
148
279
|
[key, ts, val]
|
149
280
|
end.flatten
|
150
281
|
elsif values.one? && values.first.is_a?(Array)
|
@@ -164,30 +295,50 @@ class Redis
|
|
164
295
|
cmd 'TS.MADD', args
|
165
296
|
end
|
166
297
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
298
|
+
# Get a range of values from the series
|
299
|
+
#
|
300
|
+
# @param range [Hash, Range] a time range, or hash of +from+ and +to+ values
|
301
|
+
# @param count [Integer] the maximum number of results to return
|
302
|
+
# @param aggregation [Array(<String, Symbol>, Integer), Aggregation]
|
303
|
+
# The aggregation to apply. Can be an {Aggregation} object, or an array of
|
304
|
+
# aggregation_type and duration +[:avg, 120000]+
|
305
|
+
#
|
306
|
+
# @return [Array<Sample>] an array of samples matching the range query
|
307
|
+
#
|
308
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsrangetsrevrange
|
309
|
+
def range(range, count: nil, aggregation: nil)
|
310
|
+
if range.is_a?(Hash)
|
311
|
+
# This is to support from: and to: passed in as hash keys
|
312
|
+
# `range` will swallow all parameters if they're all hash syntax
|
313
|
+
count = range.delete(:count)
|
314
|
+
aggregation = range.delete(:aggregation)
|
315
|
+
range = range.fetch(:from)..range.fetch(:to)
|
178
316
|
end
|
317
|
+
cmd('TS.RANGE',
|
318
|
+
key,
|
319
|
+
range.min,
|
320
|
+
range.max,
|
321
|
+
(['COUNT', count] if count),
|
322
|
+
Aggregation.parse(aggregation)&.to_a
|
323
|
+
).map { |ts, val| Sample.new(ts, val) }
|
179
324
|
end
|
180
325
|
|
326
|
+
# Set data retention time for the series using +TS.ALTER+
|
327
|
+
#
|
328
|
+
# @param val [Integer] the number of milliseconds data should be retained. +0+ means retain forever.
|
329
|
+
# @return [Integer] the retention value of the series
|
330
|
+
#
|
331
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsalter
|
181
332
|
def retention=(val)
|
182
|
-
|
333
|
+
# TODO: this should also accept an ActiveSupport::Duration
|
183
334
|
cmd 'TS.ALTER', key, 'RETENTION', val.to_i
|
184
335
|
end
|
185
336
|
|
186
|
-
|
187
|
-
|
188
|
-
def
|
189
|
-
|
190
|
-
|
337
|
+
# Compare series based on Redis key and configured client.
|
338
|
+
# @return [Boolean] whether the two TimeSeries objects refer to the same series
|
339
|
+
def ==(other)
|
340
|
+
return false unless other.is_a?(self.class)
|
341
|
+
key == other.key && redis == other.redis
|
191
342
|
end
|
192
343
|
end
|
193
344
|
end
|
@@ -1,6 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
class Redis
|
3
3
|
class TimeSeries
|
4
|
+
# An aggregation is a combination of a mathematical function, and a time window over
|
5
|
+
# which to apply that function. In RedisTimeSeries, aggregations are used to downsample
|
6
|
+
# data from a source series to a destination series, using compaction rules.
|
7
|
+
#
|
8
|
+
# @see Redis::TimeSeries#create_rule
|
9
|
+
# @see Redis::TimeSeries::Rule
|
10
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#aggregation-compaction-downsampling
|
4
11
|
class Aggregation
|
5
12
|
TYPES = %w[
|
6
13
|
avg
|
@@ -17,33 +24,61 @@ class Redis
|
|
17
24
|
var.s
|
18
25
|
]
|
19
26
|
|
20
|
-
|
21
|
-
|
27
|
+
# @return [String] the type of aggregation to apply
|
28
|
+
# @see TYPES
|
29
|
+
attr_reader :type
|
22
30
|
alias aggregation_type type
|
31
|
+
|
32
|
+
# @return [Integer] the time window to apply the aggregation over, in milliseconds
|
33
|
+
attr_reader :duration
|
23
34
|
alias time_bucket duration
|
24
35
|
|
36
|
+
# Parse a method argument into an aggregation.
|
37
|
+
#
|
38
|
+
# @param agg [Array, Aggregation] an aggregation object, or an array of type and duration +[:avg, 60000]+
|
39
|
+
# @return [Aggregation] the parsed aggregation, or the original argument if already an aggregation
|
40
|
+
# @raise [AggregationError] when given an unparseable value
|
25
41
|
def self.parse(agg)
|
42
|
+
return unless agg
|
26
43
|
return agg if agg.is_a?(self)
|
27
44
|
return new(agg.first, agg.last) if agg.is_a?(Array) && agg.size == 2
|
28
45
|
raise AggregationError, "Couldn't parse #{agg} into an aggregation rule!"
|
29
46
|
end
|
30
47
|
|
48
|
+
# Create a new Aggregation given a type and duration.
|
49
|
+
# @param type [String, Symbol] one of the valid aggregation {TYPES}
|
50
|
+
# @param duration [Integer, ActiveSupport::Duration]
|
51
|
+
# A time window to apply this aggregation over.
|
52
|
+
# If you're using ActiveSupport, duration objects (e.g. +10.minutes+) will be automatically coerced.
|
53
|
+
# @return [Aggregation]
|
54
|
+
# @raise [AggregationError] if the given aggregation type is not valid
|
31
55
|
def initialize(type, duration)
|
32
|
-
|
56
|
+
type = type.to_s.downcase
|
57
|
+
unless TYPES.include? type
|
33
58
|
raise AggregationError, "#{type} is not a valid aggregation type!"
|
34
59
|
end
|
35
|
-
@type = type
|
36
|
-
|
60
|
+
@type = type
|
61
|
+
if defined?(ActiveSupport::Duration) && duration.is_a?(ActiveSupport::Duration)
|
62
|
+
@duration = duration.in_milliseconds
|
63
|
+
else
|
64
|
+
@duration = duration.to_i
|
65
|
+
end
|
37
66
|
end
|
38
67
|
|
68
|
+
# @api private
|
69
|
+
# @return [Array]
|
39
70
|
def to_a
|
40
71
|
['AGGREGATION', type, duration]
|
41
72
|
end
|
42
73
|
|
74
|
+
# @api private
|
75
|
+
# @return [String]
|
43
76
|
def to_s
|
44
77
|
to_a.join(' ')
|
45
78
|
end
|
46
79
|
|
80
|
+
# Compares aggregations based on type and duration.
|
81
|
+
# @return [Boolean] whether the given aggregations are equivalent
|
47
82
|
def ==(other)
|
48
83
|
parsed = self.class.parse(other)
|
49
84
|
type == parsed.type && duration == parsed.duration
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
using TimeMsec
|
3
|
+
|
4
|
+
class Redis
|
5
|
+
class TimeSeries
|
6
|
+
# The client module handles connection management for individual time series, and
|
7
|
+
# the parent {TimeSeries} class methods. You can enable or disable debugging, and set
|
8
|
+
# a default Redis client to use for time series objects.
|
9
|
+
module Client
|
10
|
+
def self.extended(base)
|
11
|
+
base.class_eval do
|
12
|
+
attr_reader :redis
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def cmd(name, *args)
|
17
|
+
self.class.send :cmd_with_redis, redis, name, *args
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check debug status. Defaults to on with +DEBUG=true+ environment variable.
|
23
|
+
# @return [Boolean] current debug status
|
24
|
+
def debug
|
25
|
+
@debug.nil? ? [true, 'true', 1].include?(ENV['DEBUG']) : @debug
|
26
|
+
end
|
27
|
+
|
28
|
+
# Enable or disable debug output for time series commands. Enabling debug will
|
29
|
+
# print commands to +STDOUT+ as they're executed.
|
30
|
+
#
|
31
|
+
# @example
|
32
|
+
# [1] pry(main)> @ts1.get
|
33
|
+
# => #<Redis::TimeSeries::Sample:0x00007fc82e9de150 @time=2020-07-19 15:01:13 -0700, @value=0.56e2>
|
34
|
+
# [2] pry(main)> Redis::TimeSeries.debug = true
|
35
|
+
# => true
|
36
|
+
# [3] pry(main)> @ts1.get
|
37
|
+
# DEBUG: TS.GET ts1
|
38
|
+
# => #<Redis::TimeSeries::Sample:0x00007fc82f11b7b0 @time=2020-07-19 15:01:13 -0700, @value=0.56e2>
|
39
|
+
#
|
40
|
+
# @return [Boolean] new debug status
|
41
|
+
def debug=(bool)
|
42
|
+
@debug = !!bool
|
43
|
+
end
|
44
|
+
|
45
|
+
# @return [Redis] the current Redis client. Defaults to +Redis.current+
|
46
|
+
def redis
|
47
|
+
@redis ||= Redis.current
|
48
|
+
end
|
49
|
+
|
50
|
+
# Set the default Redis client for time series objects.
|
51
|
+
# This may be useful if you already use a non-time-series Redis database, and want
|
52
|
+
# to use both at the same time.
|
53
|
+
#
|
54
|
+
# @example
|
55
|
+
# # config/initializers/redis_time_series.rb
|
56
|
+
# Redis::TimeSeries.redis = Redis.new(url: 'redis://my-redis-server:6379/0')
|
57
|
+
#
|
58
|
+
# @param client [Redis] a Redis client
|
59
|
+
# @return [Redis]
|
60
|
+
def redis=(client)
|
61
|
+
@redis = client
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def cmd(name, *args)
|
67
|
+
cmd_with_redis redis, name, *args
|
68
|
+
end
|
69
|
+
|
70
|
+
def cmd_with_redis(redis, name, *args)
|
71
|
+
args = args.flatten.compact.map { |arg| arg.is_a?(Time) ? arg.ts_msec : arg }
|
72
|
+
puts "DEBUG: #{name} #{args.join(' ')}" if debug
|
73
|
+
redis.call name, args
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -1,13 +1,17 @@
|
|
1
1
|
class Redis
|
2
2
|
class TimeSeries
|
3
|
-
# Base error class for convenient
|
4
|
-
|
3
|
+
# Base error class for convenient +rescue+-ing.
|
4
|
+
#
|
5
|
+
# Descendant of +Redis::BaseError+, so you can rescue that and capture all
|
6
|
+
# time-series errors, as well as standard Redis command errors.
|
7
|
+
class Error < Redis::BaseError; end
|
5
8
|
|
6
|
-
#
|
7
|
-
#
|
9
|
+
# +FilterError+ is raised when a given set of filters is invalid (i.e. does not contain
|
10
|
+
# a equality comparison "foo=bar"), or the filter value is unparseable.
|
11
|
+
# @see Redis::TimeSeries::Filters
|
8
12
|
class FilterError < Error; end
|
9
13
|
|
10
|
-
#
|
14
|
+
# +AggregationError+ is raised when attempting to create an aggreation with
|
11
15
|
# an unknown type, or when calling a command with an invalid aggregation value.
|
12
16
|
# @see Redis::TimeSeries::Aggregation
|
13
17
|
class AggregationError < Error; end
|
@@ -1,29 +1,79 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
class Redis
|
3
3
|
class TimeSeries
|
4
|
+
# The Info struct wraps the result of the +TS.INFO+ command with method access.
|
5
|
+
# It also applies some limited parsing to the result values, mainly snakifying
|
6
|
+
# the property keys, and instantiating Rule objects if necessary.
|
7
|
+
#
|
8
|
+
# All properties of the struct are also available on a TimeSeries object itself
|
9
|
+
# via delegation.
|
10
|
+
#
|
11
|
+
# @!attribute [r] chunk_count
|
12
|
+
# @return [Integer] number of memory chunks used for the time-series
|
13
|
+
# @!attribute [r] first_timestamp
|
14
|
+
# @return [Integer] first timestamp present in the time-series (milliseconds since epoch)
|
15
|
+
# @!attribute [r] labels
|
16
|
+
# @return [Hash] a hash of label-value pairs that represent metadata labels of the time-series
|
17
|
+
# @!attribute [r] last_timestamp
|
18
|
+
# @return [Integer] last timestamp present in the time-series (milliseconds since epoch)
|
19
|
+
# @!attribute [r] max_samples_per_chunk
|
20
|
+
# @return [Integer] maximum number of samples per memory chunk
|
21
|
+
# @!attribute [r] memory_usage
|
22
|
+
# @return [Integer] total number of bytes allocated for the time-series
|
23
|
+
# @!attribute [r] retention_time
|
24
|
+
# @return [Integer] retention time, in milliseconds, for the time-series.
|
25
|
+
# A zero value means unlimited retention.
|
26
|
+
# @!attribute [r] rules
|
27
|
+
# @return [Array<Rule>] an array of configured compaction {Rule}s
|
28
|
+
# @!attribute [r] series
|
29
|
+
# @return [TimeSeries] the series this info is from
|
30
|
+
# @!attribute [r] source_key
|
31
|
+
# @return [String, nil] the key of the source series, if this series is the destination
|
32
|
+
# of a compaction rule
|
33
|
+
# @!attribute [r] total_samples
|
34
|
+
# @return [Integer] the total number of samples in the series
|
35
|
+
#
|
36
|
+
# @see TimeSeries#info
|
37
|
+
# @see https://oss.redislabs.com/redistimeseries/commands/#tsinfo
|
4
38
|
Info = Struct.new(
|
5
|
-
:
|
6
|
-
:memory_usage,
|
39
|
+
:chunk_count,
|
7
40
|
:first_timestamp,
|
41
|
+
:labels,
|
8
42
|
:last_timestamp,
|
9
|
-
:retention_time,
|
10
|
-
:chunk_count,
|
11
43
|
:max_samples_per_chunk,
|
12
|
-
:
|
13
|
-
:
|
44
|
+
:memory_usage,
|
45
|
+
:retention_time,
|
14
46
|
:rules,
|
47
|
+
:series,
|
48
|
+
:source_key,
|
49
|
+
:total_samples,
|
15
50
|
keyword_init: true
|
16
51
|
) do
|
17
|
-
|
18
|
-
|
52
|
+
# @api private
|
53
|
+
# @return [Info]
|
54
|
+
def self.parse(series:, data:)
|
55
|
+
data.each_slice(2).reduce({}) do |h, (key, value)|
|
19
56
|
# Convert camelCase info keys to snake_case
|
20
57
|
h[key.gsub(/(.)([A-Z])/,'\1_\2').downcase] = value
|
21
58
|
h
|
22
59
|
end.then do |parsed_hash|
|
60
|
+
parsed_hash['series'] = series
|
23
61
|
parsed_hash['labels'] = parsed_hash['labels'].to_h
|
62
|
+
parsed_hash['rules'] = parsed_hash['rules'].map { |d| Rule.new(source: series, data: d) }
|
24
63
|
new(parsed_hash)
|
25
64
|
end
|
26
65
|
end
|
66
|
+
|
67
|
+
alias count total_samples
|
68
|
+
alias length total_samples
|
69
|
+
alias size total_samples
|
70
|
+
|
71
|
+
# If this series is the destination of a compaction rule, returns the source series of the data.
|
72
|
+
# @return [TimeSeries, nil] the series referred to by {source_key}
|
73
|
+
def source
|
74
|
+
return unless source_key
|
75
|
+
@source ||= TimeSeries.new(source_key, redis: series.redis)
|
76
|
+
end
|
27
77
|
end
|
28
78
|
end
|
29
79
|
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
class Redis
|
3
|
+
class TimeSeries
|
4
|
+
# A compaction rule applies an aggregation from a source series to a destination series.
|
5
|
+
# As data is added to the source, it will be aggregated based on any configured rule(s) and
|
6
|
+
# distributed to the correct destination(s).
|
7
|
+
#
|
8
|
+
# Compaction rules are useful to retain data over long time periods without requiring exorbitant
|
9
|
+
# amounts of memory and storage. For example, if you're collecting data on a minute-by-minute basis,
|
10
|
+
# you may want to retain a week's worth of data at full fidelity, and a year's worth of data downsampled
|
11
|
+
# to hourly, which would require 60x less memory.
|
12
|
+
class Rule
|
13
|
+
# @return [Aggregation] the configured aggregation for this rule
|
14
|
+
attr_reader :aggregation
|
15
|
+
|
16
|
+
# @return [String] the Redis key of the destination series
|
17
|
+
attr_reader :destination_key
|
18
|
+
|
19
|
+
# @return [TimeSeries] the data source of this compaction rule
|
20
|
+
attr_reader :source
|
21
|
+
|
22
|
+
# Manually instantiating a rule does nothing, don't bother.
|
23
|
+
# @api private
|
24
|
+
# @see Info#rules
|
25
|
+
def initialize(source:, data:)
|
26
|
+
@source = source
|
27
|
+
@destination_key, duration, aggregation_type = data
|
28
|
+
@aggregation = Aggregation.new(aggregation_type, duration)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Delete this compaction rule.
|
32
|
+
# @return [String] the string "OK"
|
33
|
+
def delete
|
34
|
+
source.delete_rule(dest: destination_key)
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [TimeSeries] the destination time series this rule refers to
|
38
|
+
def destination
|
39
|
+
@dest ||= TimeSeries.new(destination_key, redis: source.redis)
|
40
|
+
end
|
41
|
+
alias dest destination
|
42
|
+
|
43
|
+
# @return [String] the Redis key of the source series
|
44
|
+
def source_key
|
45
|
+
source.key
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -1,23 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
class Redis
|
3
3
|
class TimeSeries
|
4
|
+
# A sample is an immutable value object that represents a single data point within a time series.
|
4
5
|
class Sample
|
5
|
-
|
6
|
+
using TimeMsec
|
6
7
|
|
7
|
-
|
8
|
+
# @return [Time] the sample's timestamp
|
9
|
+
attr_reader :time
|
10
|
+
# @return [BigDecimal] the decimal value of the sample
|
11
|
+
attr_reader :value
|
8
12
|
|
13
|
+
# Samples are returned by time series query methods, there's no need to create one yourself.
|
14
|
+
# @api private
|
15
|
+
# @see TimeSeries#get
|
16
|
+
# @see TimeSeries#range
|
9
17
|
def initialize(timestamp, value)
|
10
|
-
@time = Time.
|
18
|
+
@time = Time.from_msec(timestamp)
|
11
19
|
@value = BigDecimal(value)
|
12
20
|
end
|
13
21
|
|
14
|
-
|
15
|
-
|
22
|
+
# @return [Integer] the millisecond value of the sample's timestamp
|
23
|
+
# @note
|
24
|
+
# We're wrapping the method provided by the {TimeMsec} refinement for convenience,
|
25
|
+
# otherwise it wouldn't be callable on {time} and devs would have to litter
|
26
|
+
# +using TimeMsec+ or +* 1000.0+ wherever they wanted the value.
|
27
|
+
def to_msec
|
28
|
+
time.ts_msec
|
16
29
|
end
|
17
30
|
|
31
|
+
# @return [Hash] a hash representation of the sample
|
32
|
+
# @example
|
33
|
+
# {:timestamp=>1595199272401, :value=>0.2e1}
|
18
34
|
def to_h
|
19
35
|
{
|
20
|
-
timestamp:
|
36
|
+
timestamp: to_msec,
|
21
37
|
value: value
|
22
38
|
}
|
23
39
|
end
|
data/redis-time-series.gemspec
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
lib = File.expand_path('../lib', __FILE__)
|
3
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require 'redis
|
4
|
+
require 'redis/time_series/version'
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = 'redis-time-series'
|
8
|
-
spec.version =
|
8
|
+
spec.version = Redis::TimeSeries::VERSION
|
9
9
|
spec.authors = ['Matt Duszynski']
|
10
|
-
spec.email = ['
|
10
|
+
spec.email = ['dzunk@hey.com']
|
11
11
|
|
12
12
|
spec.summary = %q{A Ruby adapter for the RedisTimeSeries module.}
|
13
13
|
# spec.description = %q{TODO: Write a longer description or delete this line.}
|
@@ -38,4 +38,5 @@ Gem::Specification.new do |spec|
|
|
38
38
|
spec.add_development_dependency 'pry', '~> 0.13'
|
39
39
|
spec.add_development_dependency 'rake', '~> 13.0'
|
40
40
|
spec.add_development_dependency 'rspec', '~> 3.0'
|
41
|
+
spec.add_development_dependency 'simplecov', '< 0.18' # https://github.com/codeclimate/test-reporter/issues/413
|
41
42
|
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.5.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-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -94,9 +94,23 @@ dependencies:
|
|
94
94
|
- - "~>"
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '3.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: simplecov
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "<"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0.18'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "<"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0.18'
|
97
111
|
description:
|
98
112
|
email:
|
99
|
-
-
|
113
|
+
- dzunk@hey.com
|
100
114
|
executables: []
|
101
115
|
extensions: []
|
102
116
|
extra_rdoc_files: []
|
@@ -116,10 +130,13 @@ files:
|
|
116
130
|
- lib/redis-time-series.rb
|
117
131
|
- lib/redis/time_series.rb
|
118
132
|
- lib/redis/time_series/aggregation.rb
|
133
|
+
- lib/redis/time_series/client.rb
|
119
134
|
- lib/redis/time_series/errors.rb
|
120
135
|
- lib/redis/time_series/filters.rb
|
121
136
|
- lib/redis/time_series/info.rb
|
137
|
+
- lib/redis/time_series/rule.rb
|
122
138
|
- lib/redis/time_series/sample.rb
|
139
|
+
- lib/redis/time_series/version.rb
|
123
140
|
- redis-time-series.gemspec
|
124
141
|
homepage: https://github.com/dzunk/redis-time-series
|
125
142
|
licenses:
|