RSpec Gem Version Documentation Maintainability Test Coverage

RedisTimeSeries

A Ruby adapter for the RedisTimeSeries module.

This doesn't work with vanilla Redis, you need the time series module compiled and installed. Try it with Docker, and see the module setup guide for additional options.

docker run -p 6379:6379 -it --rm redislabs/redistimeseries

TL;DR

require 'redis-time-series'
ts = Redis::TimeSeries.new('foo')
ts.add 1234
=> #<Redis::TimeSeries::Sample:0x00007f8c0d2561d8 @time=2020-06-25 23:23:04 -0700, @value=0.1234e4>
ts.add 56
=> #<Redis::TimeSeries::Sample:0x00007f8c0d26c460 @time=2020-06-25 23:23:16 -0700, @value=0.56e2>
ts.add 78
=> #<Redis::TimeSeries::Sample:0x00007f8c0d276618 @time=2020-06-25 23:23:20 -0700, @value=0.78e2>
ts.range (Time.now.to_i - 100)..Time.now.to_i * 1000
=> [#<Redis::TimeSeries::Sample:0x00007f8c0d297200 @time=2020-06-25 23:23:04 -0700, @value=0.1234e4>,
 #<Redis::TimeSeries::Sample:0x00007f8c0d297048 @time=2020-06-25 23:23:16 -0700, @value=0.56e2>,
 #<Redis::TimeSeries::Sample:0x00007f8c0d296e90 @time=2020-06-25 23:23:20 -0700, @value=0.78e2>]

Installation

Add this line to your application's Gemfile:

gem 'redis-time-series'

And then execute:

$ bundle

Or install it yourself as:

$ gem install redis-time-series

Usage

Check out the Redis Time Series command documentation first. Should be able to do most of that.

Configuring

You can set the default Redis client for class-level calls operating on multiple series, as well as series created without specifying a client.

Redis::TimeSeries.redis = Redis.new(url: ENV['REDIS_URL'], timeout: 1)

Creating a Series

Create a series (issues TS.CREATE command) and return a Redis::TimeSeries object for further use. Key param is required, all other arguments are optional.

ts = Redis::TimeSeries.create(
  'your_ts_key',
  labels: { foo: 'bar' },
  retention: 600,
  uncompressed: false,
  redis: Redis.new(url: ENV['REDIS_URL']) # defaults to Redis::TimeSeries.redis
)

You can also call .new instead of .create to skip the TS.CREATE command.

ts = Redis::TimeSeries.new('your_ts_key')

Adding Data to a Series

Add a single value

ts.add 1234
=> #<Redis::TimeSeries::Sample:0x00007f8c0ea7edc8 @time=2020-06-25 23:41:29 -0700, @value=0.1234e4>

Add a single value with a timestamp

ts.add 1234, 3.minutes.ago # Used ActiveSupport here, but any Time object works fine
=> #<Redis::TimeSeries::Sample:0x00007fa6ce05f3f8 @time=2020-06-25 23:39:54 -0700, @value=0.1234e4>

# Optionally store data uncompressed
ts.add 5678, uncompressed: true
=> #<Redis::TimeSeries::Sample:0x00007f93f43cdf68 @time=2020-07-18 23:15:29 -0700, @value=0.5678e4>

Add multiple values with timestamps

ts.madd(2.minutes.ago => 12, 1.minute.ago => 34, Time.now => 56)
=> [1593153909466, 1593153969466, 1593154029466]

Increment or decrement the most recent value

ts.incrby 2
=> 1593154222877
ts.decrby 1
=> 1593154251392
ts.increment # alias of incrby
=> 1593154255069
ts.decrement # alias of decrby
=> 1593154257344

# Optionally store data uncompressed
ts.incrby 4, uncompressed: true
=> 1595139299769
ts.get
=> #<Redis::TimeSeries::Sample:0x00007fa25f17ed88 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>
ts.increment
=> 1593154290736
ts.get
=> #<Redis::TimeSeries::Sample:0x00007fa25f199480 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>

Add values to multiple series

# Without timestamp (series named "foo" and "bar")
Redis::TimeSeries.madd(foo: 1234, bar: 5678)
=> [#<Redis::TimeSeries::Sample:0x00007ffb3aa32ae0 @time=2020-06-26 00:09:15 -0700, @value=0.1234e4>,
 #<Redis::TimeSeries::Sample:0x00007ffb3aa326d0 @time=2020-06-26 00:09:15 -0700, @value=0.5678e4>]
# With a timestamp
Redis::TimeSeries.madd(foo: { 1.minute.ago => 1234 }, bar: { 1.minute.ago => 2345 })
=> [#<Redis::TimeSeries::Sample:0x00007fb102431f88 @time=2020-06-26 00:10:22 -0700, @value=0.1234e4>,
 #<Redis::TimeSeries::Sample:0x00007fb102431d80 @time=2020-06-26 00:10:22 -0700, @value=0.2345e4>]

Querying a Series

Get the most recent value

ts.get
=> #<Redis::TimeSeries::Sample:0x00007fa25f1b78b8 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>

Get a range of values

# Time range as an argument
ts.range(10.minutes.ago..Time.current)
=> [#<Redis::TimeSeries::Sample:0x00007fa25f13fc28 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25f13db58 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25f13d900 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25f13d680 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]

# Time range as keyword args
ts.range(from: 10.minutes.ago, to: Time.current)
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25dc01d20 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25dc01b68 @time=2020-06-25 23:50:57 -0700, @value=0.57e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25dc019b0 @time=2020-06-25 23:51:30 -0700, @value=0.58e2>]

# Limit number of results with count argument
ts.range(10.minutes.ago..Time.current, count: 2)
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:51 -0700, @value=0.57e2>,
    #<Redis::TimeSeries::Sample:0x00007fa25dc01d20 @time=2020-06-25 23:50:55 -0700, @value=0.58e2>]

# Apply aggregations to the range
ts.range(from: 10.minutes.ago, to: Time.current, aggregation: [:avg, 10.minutes])
=> [#<Redis::TimeSeries::Sample:0x00007fa25dc01f00 @time=2020-06-25 23:50:00 -0700, @value=0.575e2>]

Get info about the series

ts.info
=> #<struct Redis::TimeSeries::Info
 series=
  #<Redis::TimeSeries:0x00007ff46da9b578 @key="ts3", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>,
 total_samples=3,
 memory_usage=4264,
 first_timestamp=1595187993605,
 last_timestamp=1595187993629,
 retention_time=0,
 chunk_count=1,
 max_samples_per_chunk=256,
 labels={"foo"=>"bar"},
 source_key=nil,
 rules=
  [#<Redis::TimeSeries::Rule:0x00007ff46db30c68
    @aggregation=#<Redis::TimeSeries::Aggregation:0x00007ff46db30c18 @duration=3600000, @type="avg">,
    @destination_key="ts1",
    @source=
     #<Redis::TimeSeries:0x00007ff46da9b578
      @key="ts3",
      @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>>]>

# Each info property is also a method on the time series object
ts.memory_usage
=> 4208
ts.labels
=> {"foo"=>"bar"}
ts.total_samples
=> 3

# Total samples also available as #count, #length, and #size
ts.count
=> 3
ts.length
=> 3
ts.size
=> 3

Find series matching specific label(s)

Redis::TimeSeries.query_index('foo=bar')
=> [#<Redis::TimeSeries:0x00007fc115ba1610
  @key="ts3",
  @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
  @retention=nil,
  @uncompressed=false>]
# Note that you need at least one "label equals value" filter
Redis::TimeSeries.query_index('foo!=bar')
=> RuntimeError: Filtering requires at least one equality comparison
# query_index is also aliased as .where for fluency
Redis::TimeSeries.where('foo=bar')
=> [#<Redis::TimeSeries:0x00007fb8981010c8
  @key="ts3",
  @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
  @retention=nil,
  @uncompressed=false>]

Querying Multiple Series

Get all samples from matching series over a time range with mrange

[4] pry(main)> result = Redis::TimeSeries.mrange(1.minute.ago.., filter: { foo: 'bar' })
=> [#<struct Redis::TimeSeries::Multi::Result
  series=
   #<Redis::TimeSeries:0x00007f833e408ad0
    @key="ts3",
    @redis=#<Redis client v4.2.5 for redis://127.0.0.1:6379/0>>,
  labels=[],
  samples=
   [#<Redis::TimeSeries::Sample:0x00007f833e408a58
     @time=2021-06-17 20:58:33 3246391/4194304 -0700,
     @value=0.1e1>,
    #<Redis::TimeSeries::Sample:0x00007f833e408850
     @time=2021-06-17 20:58:33 413139/524288 -0700,
     @value=0.3e1>,
    #<Redis::TimeSeries::Sample:0x00007f833e408670
     @time=2021-06-17 20:58:33 1679819/2097152 -0700,
     @value=0.2e1>]>]
[5] pry(main)> result.keys
=> ["ts3"]
[6] pry(main)> result['ts3'].values
=> [0.1e1, 0.3e1, 0.2e1]

Order them from newest to oldest with mrevrange

[8] pry(main)> Redis::TimeSeries.mrevrange(1.minute.ago.., filter: { foo: 'bar' }).first.values
=> [0.2e1, 0.3e1, 0.1e1]

Filter DSL

You can provide filter strings directly, per the time series documentation.

Redis::TimeSeries.where('foo=bar')
=> [#<Redis::TimeSeries:0x00007fb8981010c8...>]

There is also a hash-based syntax available, which may be more pleasant to work with.

Redis::TimeSeries.where(foo: 'bar')
=> [#<Redis::TimeSeries:0x00007fb89811dca0...>]

All six filter types are represented in hash format below.

{
  foo: 'bar',          # label=value  (equality)
  foo: { not: 'bar' }, # label!=value (inequality)
  foo: true,           # label=       (presence)
  foo: false,          # label!=      (absence)
  foo: [1, 2],         # label=(1,2)  (any value)
  foo: { not: [1, 2] } # label!=(1,2) (no values)
}

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.

Values can be any object that responds to .to_s:

class Person
  def initialize(name)
    @name = name
  end

  def to_s
    @name
  end
end

Redis::TimeSeries.where(person: Person.new('John'))
#=> TS.QUERYINDEX person=John

Compaction Rules

Add a compaction rule to a series.

# Destintation time series needs to be created before the rule is added.
other_ts = Redis::TimeSeries.create('other_ts')

# Aggregation buckets are measured in milliseconds
ts.create_rule(dest: other_ts, aggregation: [:count, 60000]) # 1 minute

# Can provide a string key instead of a time series object
ts.create_rule(dest: 'other_ts', aggregation: [:avg, 120000])

# If you're using Rails or ActiveSupport, you can provide an
# ActiveSupport::Duration instead of an integer
ts.create_rule(dest: other_ts, aggregation: [:avg, 2.minutes])

# Can also provide an Aggregation object instead of an array
agg = Redis::TimeSeries::Aggregation.new(:avg, 120000)
ts.create_rule(dest: other_ts, aggregation: agg)

# Class-level method also available
Redis::TimeSeries.create_rule(source: ts, dest: other_ts, aggregation: ['std.p', 150000])

Get existing compaction rules

ts.rules
=> [#<Redis::TimeSeries::Rule:0x00007ff46e91c728
  @aggregation=#<Redis::TimeSeries::Aggregation:0x00007ff46e91c6d8 @duration=3600000, @type="avg">,
  @destination_key="ts1",
  @source=
   #<Redis::TimeSeries:0x00007ff46da9b578 @key="ts3", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>>]

# Get properties of a rule too
ts.rules.first.aggregation
=> #<Redis::TimeSeries::Aggregation:0x00007ff46d146d38 @duration=3600000, @type="avg">
ts.rules.first.destination
=> #<Redis::TimeSeries:0x00007ff46d8a3d60 @key="ts1", @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>>

Remove an existing compaction rule

ts.delete_rule(dest: 'other_ts')
ts.rules.first.delete
Redis::TimeSeries.delete_rule(source: ts, dest: 'other_ts')

TODO

Development

After checking out the repo, run bin/setup. You need the docker daemon installed and running. This script will:

  • Install gem dependencies
  • Pull the latest redislabs/redistimeseries image
  • Start a Redis server on port 6379
  • Seed three time series with some sample data
  • Attach to the running server and print logs to STDOUT

With the above script running, or after starting a server manually, you can run bin/console to interact with it. The three series are named ts1, ts2, and ts3, and are available as instance variables in the console.

If you want to see the commands being executed, run the console with DEBUG=true bin/console and it will output the raw command strings as they're executed.

[1] pry(main)> @ts1.increment
DEBUG: TS.INCRBY ts1 1
=> 1593159795467
[2] pry(main)> @ts1.get
DEBUG: TS.GET ts1
=> #<Redis::TimeSeries::Sample:0x00007f8e1a190cf8 @time=2020-06-26 01:23:15 -0700, @value=0.4e1>

Use rake spec to run the test suite.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/dzunk/redis-time-series.

License

The gem is available as open source under the terms of the MIT License.