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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d699daec01bb891952501de6a51cc4b966be74a0f8585030bafb4f6a908b36e
4
- data.tar.gz: dfa027d344c4d5485b0a22a33edd67973c2a60cb60dbbaa1a57101ccce8833fc
3
+ metadata.gz: 4648678522434687a6e278490c7a308c64f6460394b8ebde16b2ce2daa9e92ad
4
+ data.tar.gz: e2f48fb1c8efebb3b4d6e4c47d1d86137a10ac8a54735c94b3bdb35ef6396651
5
5
  SHA512:
6
- metadata.gz: acadcad9c6919e810f0e100bfe20b60055507e2efa98d0853fca6c15a252f99b65c722c71acc99d37d8d9f72964d91f862c9604fb96458ca3faaddce9f841752
7
- data.tar.gz: 489415e8247d8cffefcf13e4e70c82cde752170c4416bc0973495c5d38b1d27d2b554cdd62db8729c9c71b125cb4848aa5a1efd81b998aa5f6e5683c13be8ccc
6
+ metadata.gz: 269a787d8d2a2a467a52e915ab717bc5b20eb28cacc55eb5dfc9ce3909a7d83cdd4e15b19afc92612d19fa551c6ba793df76ceef03d8bb8bdb16b6dc0253d2cc
7
+ data.tar.gz: c5d845052ca8f2a518fb3a20a82a8d8aa35128cb824f54d2531a949767e90f8c7979ede07ba1bd56ae30a825edb9f6be90e52ca404a14199bfd13484026d8d40
@@ -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:latest
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: 2.6
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: bundle install
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
@@ -6,6 +6,7 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ Gemfile.lock
9
10
 
10
11
  # rspec failure tracking
11
12
  .rspec_status
@@ -0,0 +1,7 @@
1
+ appraise 'redis 3' do
2
+ gem 'redis', '~> 3.3'
3
+ end
4
+
5
+ appraise 'redis 4' do
6
+ gem 'redis', '~> 4.0'
7
+ end
@@ -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
- ts.range 10.minutes.ago..Time.current # Time range as an argument
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
- #<Redis::TimeSeries::Sample:0x00007fa25f13db58 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
126
- #<Redis::TimeSeries::Sample:0x00007fa25f13d900 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
127
- #<Redis::TimeSeries::Sample:0x00007fa25f13d680 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]
128
- ts.range from: 10.minutes.ago, to: Time.current # Time range as keyword args
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
- #<Redis::TimeSeries::Sample:0x00007fa25dc01d20 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
131
- #<Redis::TimeSeries::Sample:0x00007fa25dc01b68 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
132
- #<Redis::TimeSeries::Sample:0x00007fa25dc019b0 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]
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=4184,
140
- first_timestamp=1594060993011,
141
- last_timestamp=1594060993060,
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.queryindex('foo=bar')
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.queryindex('foo!=bar')
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
@@ -1,11 +1,15 @@
1
1
  require 'bigdecimal'
2
2
  require 'forwardable'
3
- require 'time/msec'
4
- require 'redis/time_series/filter'
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
@@ -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, **options).create(labels: options[:labels])
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
- if value.is_a?(Hash) || (value.is_a?(Array) && value.first.is_a?(Array))
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
- puts "DEBUG: TS.MADD #{args.join(' ')}" if ENV['DEBUG']
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
- def queryindex(filter_value)
37
- filters = Filter.new(filter_value)
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
- redis.call('TS.QUERYINDEX', *filters.to_a).map { |key| new(key) }
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 redis
43
- @redis ||= Redis.current
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 redis=(client)
47
- @redis = redis
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
- attr_reader :key, :redis, :retention, :uncompressed
147
+ # @return [String] the Redis key this time series is stored in
148
+ attr_reader :key
52
149
 
53
- def initialize(key, options = {})
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 = options[:redis] || self.class.redis
56
- @retention = options[:retention]
57
- @uncompressed = options[:uncompressed] || false
154
+ @redis = redis
58
155
  end
59
156
 
60
- def add(value, timestamp = '*')
61
- timestamp = timestamp.ts_msec if timestamp.is_a? Time
62
- ts = cmd 'TS.ADD', key, timestamp, value
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
- def create(labels: nil)
67
- args = [key]
68
- args << ['RETENTION', retention] if retention
69
- args << 'UNCOMPRESSED' if uncompressed
70
- args << ['LABELS', labels.to_a] if labels&.any?
71
- cmd 'TS.CREATE', args.flatten
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
- def decrby(value = 1, timestamp = nil)
76
- args = [key, value]
77
- args << timestamp if timestamp
78
- cmd 'TS.DECRBY', args
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
- def incrby(value = 1, timestamp = nil)
94
- args = [key, value]
95
- args << timestamp if timestamp
96
- cmd 'TS.INCRBY', args
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).then(&Info.method(:parse))
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.flatten
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
- def range(range, count: nil, agg: nil)
135
- if range.is_a? Hash
136
- args = range.fetch(:from), range.fetch(:to)
137
- elsif range.is_a? Range
138
- args = range.min, range.max
139
- end
140
- args.map! { |ts| (ts.to_f * 1000).to_i }
141
- args.append('COUNT', count) if count
142
- args.append('AGGREGATION', agg) if agg
143
- cmd('TS.RANGE', key, args).map do |ts, val|
144
- Sample.new(ts, val)
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
- @retention = val.to_i
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
- private
154
-
155
- def cmd(name, *args)
156
- puts "DEBUG: #{name} #{args.join(' ')}" if ENV['DEBUG']
157
- redis.call name, *args
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