33 Commits

Author SHA1 Message Date
4c6a6732bf Bump version to 0.5.0 2012-04-18 23:30:23 +01:00
737cd342ba Change wording slightly and kill a whitespace in readme 2012-04-18 23:28:50 +01:00
eaca48ca76 Keep the attribute writer together with the method definition 2012-04-18 23:26:18 +01:00
20321650bd Merge pull request #14 from czarneckid/configurable_group_separator
Allow for configurable group separator
2012-04-18 15:22:50 -07:00
David Czarnecki
fa182e618d Allow for configurable group separator 2012-04-18 14:44:36 -04:00
a9cf3938cf Bump version to 0.4.0 - Add key expiry support 2012-04-18 16:49:52 +01:00
7684e0bebc Merge branch 'key-expiry' 2012-04-18 16:43:22 +01:00
41f53b9fba Print a warning to STDERR if Redis version is less than 2.1.3
Prior to v2.1.3, any writes to keys with an expire/TTL set were
completely deleted before the write took place.
2012-04-18 16:42:27 +01:00
c0ecf4bc84 Add information about key expiry to readme 2012-04-18 16:19:03 +01:00
a8de80e69e Remove rbx from .travis.yml
Proper support for Rubinus will added in due time.
2012-04-18 15:15:14 +01:00
6429f07d5b Update .travis.yml to cover more Ruby versions 2012-04-18 15:11:13 +01:00
e15f637603 Make Model.expire act like the other options, and add tests for it 2012-04-18 15:09:36 +01:00
fccf0db68a Options are optional 2012-04-18 12:33:19 +01:00
e25ac3b85a Merge branch 'master' into key-expiry 2012-04-18 11:41:07 +01:00
ea68a91a58 Update, and add travis-ci status to readme 2012-04-18 11:33:39 +01:00
ce21b839f2 Rewrote Redistat::Sychronize rspecs
Partly thanks to Travis-CI failing for an unexplained reason, but also
cause they needed to be updated.
2012-04-18 11:23:52 +01:00
4024887b58 Small tweak to better deal with value caching 2012-04-18 11:22:27 +01:00
9a084d28a0 Attempt to fix unexplainably failing specs on travis-ci 2012-04-18 10:00:55 +01:00
e5b0aa32ed Add simplecov to tests 2012-04-18 09:55:30 +01:00
273d6cda24 Initial work to get key expiry working 2012-04-17 17:02:57 +01:00
5087f4ef45 Improve Buffer's unique key identifier 2012-04-17 16:31:31 +01:00
6502fc0f62 Wrap readme at 78 colomns 2012-04-17 13:11:34 +01:00
7b14b9b5ab Clean up whitespace 2012-04-17 13:03:38 +01:00
4d5998af91 updated readme to syntax highlight Ruby code examples 2012-02-17 10:01:30 +00:00
07bb9e4fab removed rcov development dependency 2012-01-24 11:41:59 +00:00
d34d820a8a removed .rvmrc file 2012-01-24 11:34:52 +00:00
35c9cabb00 Merge pull request #13 from sguha00/patch-1
Update README.md
2012-01-24 03:32:37 -08:00
sguha00
74e2a86680 Update README.md 2012-01-23 15:39:33 -08:00
d4289400b6 Merge travis-ci.org related changes from dev. Doesn't effect code, so I'm not bothering with a new gem version. 2011-06-22 14:43:32 +01:00
77c6db0d4e added custom config for travis-ci.org 2011-06-22 14:39:15 +01:00
39dc4d90e8 added rake to development dependencies 2011-06-22 14:37:46 +01:00
a609b19ad2 updated Buffer spec comments and pending test with new info about JRuby, 1.8.x, and 1.9.x 2011-04-19 09:48:54 +01:00
91272dfe6a Merge branch 'release/v0.3.0' into dev 2011-04-18 14:28:02 +01:00
48 changed files with 1072 additions and 782 deletions

1
.rvmrc
View File

@@ -1 +0,0 @@
rvm gemset use redistat

8
.travis.yml Normal file
View File

@@ -0,0 +1,8 @@
language: ruby
rvm:
- 1.8.7
- 1.9.2
- 1.9.3
- jruby-18mode
- jruby-19mode
- ree

309
README.md
View File

@@ -1,163 +1,263 @@
# Redistat # # Redistat [![Build Status](https://secure.travis-ci.org/jimeh/redistat.png)](http://travis-ci.org/jimeh/redistat)
A Redis-backed statistics storage and querying library written in Ruby. A Redis-backed statistics storage and querying library written in Ruby.
Redistat was originally created to replace a small hacked together statistics collection solution which was MySQL-based. When I started I had a short list of requirements: Redistat was originally created to replace a small hacked together statistics
collection solution which was MySQL-based. When I started I had a short list
of requirements:
* Store and increment/decrement integer values (counters, etc) * Store and increment/decrement integer values (counters, etc)
* Up to the second statistics available at all times * Up to the second statistics available at all times
* Screamingly fast * Screamingly fast
Redis fits perfectly with all of these requirements. It has atomic operations like increment, and it's lightning fast, meaning if the data is structured well, the initial stats reporting call will store data in a format that's instantly retrievable just as fast. Redis fits perfectly with all of these requirements. It has atomic operations
like increment, and it's lightning fast, meaning if the data is structured
well, the initial stats reporting call will store data in a format that's
instantly retrievable just as fast.
## Installation ## ## Installation
gem install redistat gem install redistat
If you are using Ruby 1.8.x, it's recommended you also install the `SystemTimer` gem, as the Redis gem will otherwise complain. If you are using Ruby 1.8.x, it's recommended you also install the
`SystemTimer` gem, as the Redis gem will otherwise complain.
## Usage (Crash Course) ## ## Usage (Crash Course)
view\_stats.rb: view\_stats.rb:
require 'redistat' ```ruby
require 'redistat'
class ViewStats class ViewStats
include Redistat::Model include Redistat::Model
end end
# if using Redistat in multiple threads set this # if using Redistat in multiple threads set this
# somewhere in the beginning of the execution stack # somewhere in the beginning of the execution stack
Redistat.thread_safe = true Redistat.thread_safe = true
```
### Simple Example ### ### Simple Example
Store: Store:
ViewStats.store('hello', {:world => 4}) ```ruby
ViewStats.store('hello', {:world => 2}, 2.hours.ago) ViewStats.store('hello', {:world => 4})
ViewStats.store('hello', {:world => 2}, 2.hours.ago)
```
Fetch: Fetch:
ViewStats.find('hello', 1.hour.ago, 1.hour.from_now).all ```ruby
#=> [{'world' => 4}] ViewStats.find('hello', 1.hour.ago, 1.hour.from_now).all
ViewStats.find('hello', 1.hour.ago, 1.hour.from_now).total #=> [{'world' => 4}]
#=> {'world' => 4} ViewStats.find('hello', 1.hour.ago, 1.hour.from_now).total
ViewStats.find('hello', 3.hour.ago, 1.hour.from_now).total #=> {'world' => 4}
#=> {'world' => 6} ViewStats.find('hello', 3.hour.ago, 1.hour.from_now).total
#=> {'world' => 6}
```
### Advanced Example ### ### Advanced Example
Store page view on product #44 from Chrome 11: Store page view on product #44 from Chrome 11:
ViewStats.store('views/product/44', {'count/chrome/11' => 1}) ```ruby
ViewStats.store('views/product/44', {'count/chrome/11' => 1})
```
Fetch product #44 stats: Fetch product #44 stats:
ViewStats.find('views/product/44', 23.hours.ago, 1.hour.from_now).total ```ruby
#=> { 'count' => 1, 'count/chrome' => 1, 'count/chrome/11' => 1 } ViewStats.find('views/product/44', 23.hours.ago, 1.hour.from_now).total
#=> { 'count' => 1, 'count/chrome' => 1, 'count/chrome/11' => 1 }
```
Store a page view on product #32 from Firefox 3: Store a page view on product #32 from Firefox 3:
ViewStats.store('views/product/32', {'count/firefox/3' => 1}) ```ruby
ViewStats.store('views/product/32', {'count/firefox/3' => 1})
```
Fetch product #32 stats: Fetch product #32 stats:
ViewStats.find('views/product/32', 23.hours.ago, 1.hour.from_now).total ```ruby
#=> { 'count' => 1, 'count/firefox' => 1, 'count/firefox/3' => 1 } ViewStats.find('views/product/32', 23.hours.ago, 1.hour.from_now).total
#=> { 'count' => 1, 'count/firefox' => 1, 'count/firefox/3' => 1 }
```
Fetch stats for all products: Fetch stats for all products:
ViewStats.find('views/product', 23.hours.ago, 1.hour.from_now).total ```ruby
#=> { 'count' => 2, ViewStats.find('views/product', 23.hours.ago, 1.hour.from_now).total
# 'count/chrome' => 1, #=> { 'count' => 2,
# 'count/chrome/11' => 1, # 'count/chrome' => 1,
# 'count/firefox' => 1, # 'count/chrome/11' => 1,
# 'count/firefox/3' => 1 } # 'count/firefox' => 1,
# 'count/firefox/3' => 1 }
```
Store a 404 error view: Store a 404 error view:
ViewStats.store('views/error/404', {'count/chrome/9' => 1}) ```ruby
ViewStats.store('views/error/404', {'count/chrome/9' => 1})
```
Fetch stats for all views across the board: Fetch stats for all views across the board:
ViewStats.find('views', 23.hours.ago, 1.hour.from_now).total ```ruby
#=> { 'count' => 3, ViewStats.find('views', 23.hours.ago, 1.hour.from_now).total
# 'count/chrome' => 2, #=> { 'count' => 3,
# 'count/chrome/9' => 1, # 'count/chrome' => 2,
# 'count/chrome/11' => 1, # 'count/chrome/9' => 1,
# 'count/firefox' => 1, # 'count/chrome/11' => 1,
# 'count/firefox/3' => 1 } # 'count/firefox' => 1,
# 'count/firefox/3' => 1 }
```
Fetch list of products known to Redistat: Fetch list of products known to Redistat:
finder = ViewStats.find('views/product', 23.hours.ago, 1.hour.from_now) ```ruby
finder.children.map { |child| child.label.me } finder = ViewStats.find('views/product', 23.hours.ago, 1.hour.from_now)
#=> [ "32", "44" ] finder.children.map { |child| child.label.me }
finder.children.map { |child| child.label.to_s } #=> [ "32", "44" ]
#=> [ "views/products/32", "views/products/44" ] finder.children.map { |child| child.label.to_s }
finder.children.map { |child| child.total } #=> [ "views/products/32", "views/products/44" ]
#=> [ { "count" => 1, "count/firefox" => 1, "count/firefox/3" => 1 }, finder.children.map { |child| child.total }
# { "count" => 1, "count/chrome" => 1, "count/chrome/11" => 1 } ] #=> [ { "count" => 1, "count/firefox" => 1, "count/firefox/3" => 1 },
# { "count" => 1, "count/chrome" => 1, "count/chrome/11" => 1 } ]
```
## Terminology ## ## Terminology
### Scope ### ### Scope
A type of global-namespace for storing data. When using the `Redistat::Model` wrapper, the scope is automatically set to the class name. In the examples above, the scope is `ViewStats`. Can be overridden by calling the `#scope` class method on your model class. A type of global-namespace for storing data. When using the `Redistat::Model`
wrapper, the scope is automatically set to the class name. In the examples
above, the scope is `ViewStats`. Can be overridden by calling the `#scope`
class method on your model class.
### Label ### ### Label
Identifier string to separate different types and groups of statistics from each other. The first argument of the `#store`, `#find`, and `#fetch` methods is the label that you're storing to, or fetching from. Identifier string to separate different types and groups of statistics from
each other. The first argument of the `#store`, `#find`, and `#fetch` methods
is the label that you're storing to, or fetching from.
Labels support multiple grouping levels by splitting the label string with `/` and storing the same stats for each level. For example, when storing data to a label called `views/product/44`, the data is stored for the label you specify, and also for `views/product` and `views`. Labels support multiple grouping levels by splitting the label string with `/`
and storing the same stats for each level. For example, when storing data to a
label called `views/product/44`, the data is stored for the label you specify,
and also for `views/product` and `views`. You may also configure a different
group separator using the `Redistat.group_separator=` method. For example:
A word of caution: Don't use a crazy number of group levels. As two levels causes twice as many `hincrby` calls to Redis as not using the grouping feature. Hence using 10 grouping levels, causes 10 times as many write calls to Redis. ```ruby
Redistat.group_separator = '|'
```
### Input Statistics Data ### A word of caution: Don't use a crazy number of group levels. As two levels
causes twice as many `hincrby` calls to Redis as not using the grouping
feature. Hence using 10 grouping levels, causes 10 times as many write calls
to Redis.
You provide Redistat with the data you want to store using a Ruby Hash. This data is then stored in a corresponding Redis hash with identical key/field names. ### Input Statistics Data
Key names in the hash also support grouping features similar to those available for Labels. Again, the more levels you use, the more write calls to Redis, so avoid using 10-15 levels. You provide Redistat with the data you want to store using a Ruby Hash. This
data is then stored in a corresponding Redis hash with identical key/field
names.
### Depth (Storage Accuracy) ### Key names in the hash also support grouping features similar to those
available for Labels. Again, the more levels you use, the more write calls to
Redis, so avoid using 10-15 levels.
Define how accurately data should be stored, and how accurately it's looked up when fetching it again. By default Redistat uses a depth value of `:hour`, which means it's impossible to separate two events which were stored at 10:18 and 10:23. In Redis they are both stored within a date key of `2011031610`. ### Depth (Storage Accuracy)
You can set depth within your model using the `#depth` class method. Available depths are: `:year`, `:month`, `:day`, `:hour`, `:min`, `:sec` Define how accurately data should be stored, and how accurately it's looked up
when fetching it again. By default Redistat uses a depth value of `:hour`,
which means it's impossible to separate two events which were stored at 10:18
and 10:23. In Redis they are both stored within a date key of `2011031610`.
### Time Ranges ### You can set depth within your model using the `#depth` class method. Available
depths are: `:year`, `:month`, `:day`, `:hour`, `:min`, `:sec`
When you fetch data, you need to specify a start and an end time. The selection behavior can seem a bit weird at first when, but makes sense when you understand how Redistat works internally. ### Time Ranges
For example, if we are using a Depth value of `:hour`, and we trigger a fetch call starting at `1.hour.ago` (13:34), till `Time.now` (14:34), only stats from 13:00:00 till 13:59:59 are returned, as they were all stored within the key for the 13th hour. If both 13:00 and 14:00 was returned, you would get results from two hole hours. Hence if you want up to the second data, use an end time of `1.hour.from_now`. When you fetch data, you need to specify a start and an end time. The
selection behavior can seem a bit weird at first when, but makes sense when
you understand how Redistat works internally.
### The Finder Object ### For example, if we are using a Depth value of `:hour`, and we trigger a fetch
call starting at `1.hour.ago` (13:34), till `Time.now` (14:34), only stats
from 13:00:00 till 13:59:59 are returned, as they were all stored within the
key for the 13th hour. If both 13:00 and 14:00 was returned, you would get
results from two whole hours. Hence if you want up to the second data, use an
end time of `1.hour.from_now`.
Calling the `#find` method on a Redistat model class returns a `Redistat::Finder` object. The finder is a lazy-loaded gateway to your data. Meaning you can create a new finder, and modify instantiated finder's label, scope, dates, and more. It does not call Redis and fetch the data until you call `#total`, `#all`, `#map`, `#each`, or `#each_with_index` on the finder. ### The Finder Object
This section does need further expanding as there's a lot to cover when it comes to the finder. Calling the `#find` method on a Redistat model class returns a
`Redistat::Finder` object. The finder is a lazy-loaded gateway to your
data. Meaning you can create a new finder, and modify instantiated finder's
label, scope, dates, and more. It does not call Redis and fetch the data until
you call `#total`, `#all`, `#map`, `#each`, or `#each_with_index` on the
finder.
This section does need further expanding as there's a lot to cover when it
comes to the finder.
## Key Expiry
## Internals ## Support for expiring keys from Redis is available, allowing you too keep
varying levels of details for X period of time. This allows you easily keep
things nice and tidy by only storing varying levels detailed stats only for as
long as you need.
### Storing / Writing ### In the below example we define how long Redis keys for varying depths are
stored. Second by second stats are available for 10 minutes, minute by minute
stats for 6 hours, hourly stats for 3 months, daily stats for 2 years, and
yearly stats are retained forever.
Redistat stores all data into a Redis hash keys. The Redis key name the used consists of three parts. The scope, label, and datetime: ```ruby
class ViewStats
include Redistat::Model
depth :sec
expire \
:sec => 10.minutes.to_i,
:min => 6.hours.to_i,
:hour => 3.months.to_i,
:day => 2.years.to_i
end
```
Keep in mind that when storing stats for a custom date in the past for
example, the expiry time for the keys will be relative to now. The values you
specify are simply passed to the `Redis#expire` method.
## Internals
### Storing / Writing
Redistat stores all data into a Redis hash keys. The Redis key name the used
consists of three parts. The scope, label, and datetime:
{scope}/{label}:{datetime} {scope}/{label}:{datetime}
For example, this... For example, this...
ViewStats.store('views/product/44', {'count/chrome/11' => 1}) ```ruby
ViewStats.store('views/product/44', {'count/chrome/11' => 1})
```
...would store the follow hash of data... ...would store the follow hash of data...
{ 'count' => 1, 'count/chrome' => 1, 'count/chrome/11' => 1 } ```ruby
{ 'count' => 1, 'count/chrome' => 1, 'count/chrome/11' => 1 }
```
...to all 12 of these Redis hash keys... ...to all 12 of these Redis hash keys...
@@ -174,54 +274,81 @@ For example, this...
ViewStats/views/product/44:20110315 ViewStats/views/product/44:20110315
ViewStats/views/product/44:2011031510 ViewStats/views/product/44:2011031510
...by creating the Redis key, and/or hash field if needed, otherwise it simply increments the already existing data. ...by creating the Redis key, and/or hash field if needed, otherwise it simply
increments the already existing data.
It would also create the following Redis sets to keep track of which child labels are available: It would also create the following Redis sets to keep track of which child
labels are available:
ViewStats.label_index: ViewStats.label_index:
ViewStats.label_index:views ViewStats.label_index:views
ViewStats.label_index:views/product ViewStats.label_index:views/product
It should now be more obvious to you why you should think about how you use the grouping capabilities so you don't go crazy and use 10-15 levels. Storing is done through Redis' `hincrby` call, which only supports a single key/field combo. Meaning the above example would call `hincrby` a total of 36 times to store the data, and `sadd` a total of 3 times to ensure the label index is accurate. 39 calls is however not a problem for Redis, most calls happen in less than 0.15ms (0.00015 seconds) on my local machine. It should now be more obvious to you why you should think about how you use
the grouping capabilities so you don't go crazy and use 10-15 levels. Storing
is done through Redis' `hincrby` call, which only supports a single key/field
combo. Meaning the above example would call `hincrby` a total of 36 times to
store the data, and `sadd` a total of 3 times to ensure the label index is
accurate. 39 calls is however not a problem for Redis, most calls happen in
less than 0.15ms (0.00015 seconds) on my local machine.
### Fetching / Reading ### ### Fetching / Reading
By default when fetching statistics, Redistat will figure out how to do the least number of reads from Redis. First it checks how long range you're fetching. If whole days, months or years for example fit within the start and end dates specified, it will fetch the one key for the day/month/year in question. It further drills down to the smaller units. By default when fetching statistics, Redistat will figure out how to do the
least number of reads from Redis. First it checks how long range you're
fetching. If whole days, months or years for example fit within the start and
end dates specified, it will fetch the one key for the day/month/year in
question. It further drills down to the smaller units.
It is also intelligent enough to not fetch each day from 3-31 of a month, instead it would fetch the data for the whole month and the first two days, which are then removed from the summary of the whole month. This means three calls to `hgetall` instead of 29 if each whole day was fetched. It is also intelligent enough to not fetch each day from 3-31 of a month,
instead it would fetch the data for the whole month and the first two days,
which are then removed from the summary of the whole month. This means three
calls to `hgetall` instead of 29 if each whole day was fetched.
### Buffer ### ### Buffer
The buffer is a new, still semi-beta, feature aimed to reduce the number of Redis `hincrby` that Redistat sends. This should only really be useful when you're hitting north of 30,000 Redis requests per second, if your Redis server has limited resources, or against my recommendation you've opted to use 10, 20, or more label grouping levels. The buffer is a new, still semi-beta, feature aimed to reduce the number of
Redis `hincrby` that Redistat sends. This should only really be useful when
you're hitting north of 30,000 Redis requests per second, if your Redis server
has limited resources, or against my recommendation you've opted to use 10,
20, or more label grouping levels.
Buffering tries to fold together multiple `store` calls into as few as possible by merging the statistics hashes from all calls and groups them based on scope, label, date depth, and more. You configure the the buffer by setting `Redistat.buffer_size` to an integer higher than 1. This basically tells Redistat how many `store` calls to buffer in memory before writing all data to Redis. Buffering tries to fold together multiple `store` calls into as few as
possible by merging the statistics hashes from all calls and groups them based
on scope, label, date depth, and more. You configure the the buffer by setting
`Redistat.buffer_size` to an integer higher than 1. This basically tells
Redistat how many `store` calls to buffer in memory before writing all data to
Redis.
## Todo ## ## Todo
* More details in Readme. * More details in Readme.
* Documentation. * Documentation.
* Anything else that becomes apparent after real-world use. * Anything else that becomes apparent after real-world use.
## Credits ## ## Credits
[Global Personals](http://globalpersonals.co.uk/) deserves a thank you. Currently the primary user of Redistat, they've allowed me to spend some company time to further develop the project. [Global Personals](http://globalpersonals.co.uk/) deserves a thank
you. Currently the primary user of Redistat, they've allowed me to spend some
company time to further develop the project.
## Note on Patches/Pull Requests ## ## Note on Patches/Pull Requests
* Fork the project. * Fork the project.
* Make your feature addition or bug fix. * Make your feature addition or bug fix.
* Add tests for it. This is important so I don't break it in a * Add tests for it. This is important so I don't break it in a
future version unintentionally. future version unintentionally.
* Commit, do not mess with rakefile, version, or history. * Commit, do not mess with rakefile, version, or history. (if you want to
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) have your own version, that is fine but bump version in a commit by itself I
can ignore when I pull)
* Send me a pull request. Bonus points for topic branches. * Send me a pull request. Bonus points for topic branches.
## License and Copyright ## ## License and Copyright
Copyright (c) 2011 Jim Myhrberg. Copyright (c) 2011 Jim Myhrberg.

View File

@@ -91,6 +91,11 @@ module Redistat
connection.flushdb connection.flushdb
end end
def group_separator
@group_separator ||= GROUP_SEPARATOR
end
attr_writer :group_separator
end end
end end

View File

@@ -95,12 +95,15 @@ module Redistat
end end
end end
# depth_limit is not needed as it's evident in key.to_s
def buffer_key(key, opts) def buffer_key(key, opts)
# depth_limit is not needed as it's evident in key.to_s # covert keys to strings, as sorting a Hash with Symbol keys fails on
opts_index = Summary.default_options.keys.sort { |a,b| a.to_s <=> b.to_s }.map do |k| # Ruby 1.8.x.
opts[k] if opts.has_key?(k) opts = opts.inject({}) do |result, (k, v)|
result[k.to_s] = v
result
end end
"#{key.to_s}:#{opts_index.join(':')}" "#{key.to_s}:#{opts.sort.flatten.join(':')}"
end end
end end

View File

@@ -4,6 +4,7 @@ module Redistat
module Connection module Connection
REQUIRED_SERVER_VERSION = "1.3.10" REQUIRED_SERVER_VERSION = "1.3.10"
MIN_EXPIRE_SERVER_VERSION = "2.1.3"
# TODO: Create a ConnectionPool instance object using Sychronize mixin to replace Connection class # TODO: Create a ConnectionPool instance object using Sychronize mixin to replace Connection class
@@ -67,6 +68,10 @@ module Redistat
def check_redis_version(conn) def check_redis_version(conn)
raise RedisServerIsTooOld if conn.info["redis_version"] < REQUIRED_SERVER_VERSION raise RedisServerIsTooOld if conn.info["redis_version"] < REQUIRED_SERVER_VERSION
if conn.info["redis_version"] < MIN_EXPIRE_SERVER_VERSION
STDOUT.puts "WARNING: You MUST upgrade Redis to v2.1.3 or later " +
"if you are using key expiry."
end
conn conn
end end

View File

@@ -56,7 +56,7 @@ module Redistat
members = db.smembers("#{scope}#{LABEL_INDEX}#{@label}") || [] # older versions of Redis returns nil members = db.smembers("#{scope}#{LABEL_INDEX}#{@label}") || [] # older versions of Redis returns nil
members.map { |member| members.map { |member|
child_label = [@label, member].reject { |i| i.nil? } child_label = [@label, member].reject { |i| i.nil? }
self.class.new(self.scope, child_label.join(GROUP_SEPARATOR), self.date, @options) self.class.new(self.scope, child_label.join(Redistat.group_separator), self.date, @options)
} }
end end

View File

@@ -13,7 +13,7 @@ module Redistat
def self.join(*args) def self.join(*args)
args = args.map {|i| i.to_s} args = args.map {|i| i.to_s}
self.new(args.reject {|i| i.blank? }.join(GROUP_SEPARATOR)) self.new(args.reject {|i| i.blank? }.join(Redistat.group_separator))
end end
def initialize(str, opts = {}) def initialize(str, opts = {})
@@ -48,16 +48,16 @@ module Redistat
end end
def me def me
self.to_s.split(GROUP_SEPARATOR).last self.to_s.split(Redistat.group_separator).last
end end
def groups def groups
return @groups unless @groups.nil? return @groups unless @groups.nil?
@groups = [] @groups = []
parent = "" parent = ""
self.to_s.split(GROUP_SEPARATOR).each do |part| self.to_s.split(Redistat.group_separator).each do |part|
if !part.blank? if !part.blank?
group = ((parent.blank?) ? "" : "#{parent}#{GROUP_SEPARATOR}") + part group = ((parent.blank?) ? "" : "#{parent}#{Redistat.group_separator}") + part
@groups << Label.new(group) @groups << Label.new(group)
parent = group parent = group
end end

View File

@@ -14,7 +14,8 @@ module Redistat
def thread_safe def thread_safe
monitor.synchronize do monitor.synchronize do
@thread_safe ||= false return @thread_safe unless @thread_safe.nil?
@thread_safe = false
end end
end end

View File

@@ -46,6 +46,14 @@ module Redistat
alias :class_name :scope alias :class_name :scope
def expire(exp = nil)
if !exp.nil?
options[:expire] = exp.is_a?(Hash) ? exp : Hash.new(exp)
else
options[:expire]
end
end
def connect_to(opts = {}) def connect_to(opts = {})
Connection.create(opts.merge(:ref => name)) Connection.create(opts.merge(:ref => name))
options[:connection_ref] = name options[:connection_ref] = name

View File

@@ -5,9 +5,12 @@ module Redistat
class << self class << self
def default_options def default_options
{ :enable_grouping => true, {
:label_indexing => true, :enable_grouping => true,
:connection_ref => nil } :label_indexing => true,
:connection_ref => nil,
:expire => {}
}
end end
def buffer def buffer
@@ -29,43 +32,47 @@ module Redistat
update(*args) unless buffer.store(*args) update(*args) unless buffer.store(*args)
end end
def update(key, stats, depth_limit, opts) def update(key, stats, depth_limit, opts = {})
if opts[:enable_grouping] if opts[:enable_grouping]
stats = inject_group_summaries(stats) stats = inject_group_summaries(stats)
key.groups.each do |k| key.groups.each do |k|
update_key(k, stats, depth_limit, opts[:connection_ref]) update_key(k, stats, depth_limit, opts)
k.update_index if opts[:label_indexing] k.update_index if opts[:label_indexing]
end end
else else
update_key(key, stats, depth_limit, opts[:connection_ref]) update_key(key, stats, depth_limit, opts)
end end
end end
private private
def update_key(key, stats, depth_limit, connection_ref) def update_key(key, stats, depth_limit, opts = {})
Date::DEPTHS.each do |depth| Date::DEPTHS.each do |depth|
update_fields(key, stats, depth, connection_ref) update_fields(key, stats, depth, opts)
break if depth == depth_limit break if depth == depth_limit
end end
end end
def update_fields(key, stats, depth, connection_ref = nil) def update_fields(key, stats, depth, opts = {})
stats.each do |field, value| stats.each do |field, value|
db(connection_ref).hincrby key.to_s(depth), field, value db(opts[:connection_ref]).hincrby key.to_s(depth), field, value
end
if opts[:expire] && !opts[:expire][depth].nil?
db(opts[:connection_ref]).expire key.to_s(depth), opts[:expire][depth]
end end
end end
def inject_group_summaries!(stats) def inject_group_summaries!(stats)
summaries = {} summaries = {}
stats.each do |key, value| stats.each do |key, value|
parts = key.to_s.split(GROUP_SEPARATOR) parts = key.to_s.split(Redistat.group_separator)
parts.pop parts.pop
if parts.size > 0 if parts.size > 0
sum_parts = [] sum_parts = []
parts.each do |part| parts.each do |part|
sum_parts << part sum_parts << part
sum_key = sum_parts.join(GROUP_SEPARATOR) sum_key = sum_parts.join(Redistat.group_separator)
(summaries.has_key?(sum_key)) ? summaries[sum_key] += value : summaries[sum_key] = value (summaries.has_key?(sum_key)) ? summaries[sum_key] += value : summaries[sum_key] = value
end end
end end

View File

@@ -1,3 +1,3 @@
module Redistat module Redistat
VERSION = "0.3.0" VERSION = "0.5.0"
end end

View File

@@ -24,7 +24,8 @@ Gem::Specification.new do |s|
s.add_runtime_dependency 'redis', '>= 2.1.0' s.add_runtime_dependency 'redis', '>= 2.1.0'
s.add_runtime_dependency 'time_ext', '>= 0.2.9' s.add_runtime_dependency 'time_ext', '>= 0.2.9'
s.add_development_dependency 'rake', '>= 0.8.7'
s.add_development_dependency 'rspec', '>= 2.1.0' s.add_development_dependency 'rspec', '>= 2.1.0'
s.add_development_dependency 'rcov', '>= 0.9.9'
s.add_development_dependency 'yard', '>= 0.6.3' s.add_development_dependency 'yard', '>= 0.6.3'
s.add_development_dependency 'simplecov', '>= 0.6.1'
end end

View File

@@ -78,9 +78,11 @@ describe Redistat::Buffer do
it "should build #buffer_key correctly" do it "should build #buffer_key correctly" do
opts = {:enable_grouping => true, :label_indexing => false, :connection_ref => nil} opts = {:enable_grouping => true, :label_indexing => false, :connection_ref => nil}
@buffer.send(:buffer_key, @key, opts).should == "#{@key.to_s}::true:false" @buffer.send(:buffer_key, @key, opts).should ==
"#{@key.to_s}:connection_ref::enable_grouping:true:label_indexing:false"
opts = {:enable_grouping => false, :label_indexing => true, :connection_ref => :omg} opts = {:enable_grouping => false, :label_indexing => true, :connection_ref => :omg}
@buffer.send(:buffer_key, @key, opts).should == "#{@key.to_s}:omg:false:true" @buffer.send(:buffer_key, @key, opts).should ==
"#{@key.to_s}:connection_ref:omg:enable_grouping:false:label_indexing:true"
end end
describe "Buffering" do describe "Buffering" do
@@ -119,9 +121,9 @@ describe Redistat::Buffer do
describe "Thread-Safety" do describe "Thread-Safety" do
it "should read/write to buffer queue in a thread-safe manner" do it "should read/write to buffer queue in a thread-safe manner" do
# This spec passes wether thread safety is enabled or not. In short I need # Setting thread_safe to false only makes the spec fail with
# better specs for thread-safety, and personally a better understanding of # JRuby. 1.8.x and 1.9.x both pass fine for some reason
# thread-safety in general. # regardless of what the thread_safe option is set to.
Redistat.thread_safe = true Redistat.thread_safe = true
key = mock('Key', :to_s => "Scope/labelx:2011") key = mock('Key', :to_s => "Scope/labelx:2011")
@@ -150,7 +152,7 @@ describe Redistat::Buffer do
threads.each { |t| t.join } threads.each { |t| t.join }
end end
it "should have better specs that actually fail when thread-safety is off" it "should have specs that fail on 1.8.x/1.9.x when thread_safe is disabled"
end end

View File

@@ -38,6 +38,21 @@ describe Redistat::Label do
label.to_s.should == 'email/message/public' label.to_s.should == 'email/message/public'
end end
it "should allow you to use a different group separator" do
include Redistat
Redistat.group_separator = '|'
label = Label.join('email', 'message', 'public')
label.should be_a(Label)
label.to_s.should == 'email|message|public'
label = Label.join(Label.new('email'), Label.new('message'), Label.new('public'))
label.should be_a(Label)
label.to_s.should == 'email|message|public'
label = Label.join('email', '', 'message', nil, 'public')
label.should be_a(Label)
label.to_s.should == 'email|message|public'
Redistat.group_separator = Redistat::GROUP_SEPARATOR
end
describe "Grouping" do describe "Grouping" do
before(:each) do before(:each) do
@name = "message/public/offensive" @name = "message/public/offensive"

View File

@@ -26,5 +26,6 @@ class ModelHelper4
include Redistat::Model include Redistat::Model
scope "FancyHelper" scope "FancyHelper"
expire :hour => 24*3600
end end

View File

@@ -38,6 +38,7 @@ describe Redistat::Model do
ModelHelper2.store_event.should == true ModelHelper2.store_event.should == true
ModelHelper2.hashed_label.should == true ModelHelper2.hashed_label.should == true
ModelHelper2.scope.should be_nil ModelHelper2.scope.should be_nil
ModelHelper2.expire.should be_nil
ModelHelper1.depth.should == nil ModelHelper1.depth.should == nil
ModelHelper1.store_event.should == nil ModelHelper1.store_event.should == nil
@@ -57,6 +58,7 @@ describe Redistat::Model do
ModelHelper4.scope.should == "FancyHelper" ModelHelper4.scope.should == "FancyHelper"
ModelHelper4.send(:name).should == "FancyHelper" ModelHelper4.send(:name).should == "FancyHelper"
ModelHelper4.expire.should == {:hour => 24*3600}
end end
it "should store and fetch stats" do it "should store and fetch stats" do
@@ -197,8 +199,3 @@ describe Redistat::Model do
end end
end end

View File

@@ -2,6 +2,12 @@
$LOAD_PATH.unshift(File.dirname(__FILE__)) $LOAD_PATH.unshift(File.dirname(__FILE__))
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'simplecov'
SimpleCov.start do
add_filter '/spec'
add_filter '/vendor'
end
# require stuff # require stuff
require 'redistat' require 'redistat'
require 'rspec' require 'rspec'

View File

@@ -10,6 +10,7 @@ describe Redistat::Summary do
@date = Time.now @date = Time.now
@key = Redistat::Key.new(@scope, @label, @date, {:depth => :day}) @key = Redistat::Key.new(@scope, @label, @date, {:depth => :day})
@stats = {"views" => 3, "visitors" => 2} @stats = {"views" => 3, "visitors" => 2}
@expire = {:hour => 24*3600}
end end
it "should update a single summary properly" do it "should update a single summary properly" do
@@ -32,6 +33,20 @@ describe Redistat::Summary do
summary["visitors"].should == "1" summary["visitors"].should == "1"
end end
it "should set key expiry properly" do
Redistat::Summary.update_all(@key, @stats, :hour,{:expire => @expire})
((24*3600)-1..(24*3600)+1).should include(db.ttl(@key.to_s(:hour)))
[:day, :month, :year].each do |depth|
db.ttl(@key.to_s(depth)).should == -1
end
db.flushdb
Redistat::Summary.update_all(@key, @stats, :hour, {:expire => {}})
[:hour, :day, :month, :year].each do |depth|
db.ttl(@key.to_s(depth)).should == -1
end
end
it "should update all summaries properly" do it "should update all summaries properly" do
Redistat::Summary.update_all(@key, @stats, :sec) Redistat::Summary.update_all(@key, @stats, :sec)
[:year, :month, :day, :hour, :min, :sec, :usec].each do |depth| [:year, :month, :day, :hour, :min, :sec, :usec].each do |depth|
@@ -121,12 +136,41 @@ describe Redistat::Summary do
summary["visitors/us"].should == "8" summary["visitors/us"].should == "8"
end end
it "should store label-based grouping enabled stats using a different group separator" do
Redistat.group_separator = '|'
stats = {"views" => 3, "visitors|eu" => 2, "visitors|us" => 4}
label = "views|about_us"
key = Redistat::Key.new(@scope, label, @date)
Redistat::Summary.update_all(key, stats, :hour)
key.groups[0].label.to_s.should == "views|about_us"
key.groups[1].label.to_s.should == "views"
child1 = key.groups[0]
parent = key.groups[1]
label = "views|contact"
key = Redistat::Key.new(@scope, label, @date)
Redistat::Summary.update_all(key, stats, :hour)
key.groups[0].label.to_s.should == "views|contact"
key.groups[1].label.to_s.should == "views"
child2 = key.groups[0]
summary = db.hgetall(child1.to_s(:hour))
summary["views"].should == "3"
summary["visitors|eu"].should == "2"
summary["visitors|us"].should == "4"
summary = db.hgetall(child2.to_s(:hour))
summary["views"].should == "3"
summary["visitors|eu"].should == "2"
summary["visitors|us"].should == "4"
summary = db.hgetall(parent.to_s(:hour))
summary["views"].should == "6"
summary["visitors|eu"].should == "4"
summary["visitors|us"].should == "8"
Redistat.group_separator = Redistat::GROUP_SEPARATOR
end
end end

View File

@@ -1,63 +1,124 @@
require "spec_helper" require "spec_helper"
describe Redistat::Synchronize do module Redistat
it { should respond_to(:monitor) } describe Synchronize do
it { should respond_to(:thread_safe) }
it { should respond_to(:thread_safe=) }
describe "instanciated class with Redistat::Synchronize included" do let(:klass) { Synchronize }
subject { SynchronizeSpecHelper.new }
it { should respond_to(:monitor) }
it { should respond_to(:thread_safe) }
it { should respond_to(:thread_safe=) }
it { should respond_to(:synchronize) }
end describe '.included' do
it 'includes InstanceMethods in passed object' do
base = mock('Base')
base.should_receive(:include).with(klass::InstanceMethods)
klass.included(base)
end
end # included
describe "#synchronize method" do describe '.monitor' do
it 'returns a Monitor instance' do
klass.monitor.should be_a(Monitor)
end
it 'caches Monitor instance' do
klass.monitor.object_id.should == klass.monitor.object_id
end
end # monitor
describe '.thread_safe' do
after { klass.instance_variable_set('@thread_safe', nil) }
it 'returns value of @thread_safe' do
klass.instance_variable_set('@thread_safe', true)
klass.thread_safe.should be_true
end
it 'defaults to false' do
klass.thread_safe.should be_false
end
it 'uses #synchronize' do
klass.monitor.should_receive(:synchronize).once
klass.thread_safe.should be_nil
end
end # thread_safe
describe '.thread_safe=' do
after { klass.instance_variable_set('@thread_safe', nil) }
it 'sets @thread_safe' do
klass.instance_variable_get('@thread_safe').should be_nil
klass.thread_safe = true
klass.instance_variable_get('@thread_safe').should be_true
end
it 'uses #synchronize' do
klass.monitor.should_receive(:synchronize).once
klass.thread_safe = true
klass.instance_variable_get('@thread_safe').should be_nil
end
end # thread_safe=
describe "InstanceMethods" do
subject { SynchronizeSpecHelper.new }
describe '.monitor' do
it 'defers to Redistat::Synchronize' do
klass.should_receive(:monitor).once
subject.monitor
end
end # monitor
describe '.thread_safe' do
it ' defers to Redistat::Synchronize' do
klass.should_receive(:thread_safe).once
subject.thread_safe
end
end # thread_safe
describe '.thread_safe=' do
it 'defers to Redistat::Synchronize' do
klass.should_receive(:thread_safe=).once.with(true)
subject.thread_safe = true
end
end # thread_safe=
describe 'when #thread_safe is true' do
before { subject.stub(:thread_safe).and_return(true) }
describe '.synchronize' do
it 'defers to #monitor' do
subject.monitor.should_receive(:synchronize).once
subject.synchronize { 'foo' }
end
it 'passes block along to #monitor.synchronize' do
yielded = false
subject.synchronize { yielded = true }
yielded.should be_true
end
end # synchronize
end # when #thread_safe is true
describe 'when #thread_safe is false' do
before { subject.stub(:thread_safe).and_return(false) }
describe '.synchronize' do
it 'does not defer to #monitor' do
subject.monitor.should_not_receive(:synchronize)
subject.synchronize { 'foo' }
end
it 'yields block' do
yielded = false
subject.synchronize { yielded = true }
yielded.should be_true
end
end # synchronize
end # when #thread_safe is false
before(:each) do
Redistat::Synchronize.instance_variable_set("@thread_safe", nil)
@obj = SynchronizeSpecHelper.new
end end
it "should share single Monitor object across all objects" do end # Synchronize
@obj.monitor.should == Redistat::Synchronize.monitor end # Redistat
end
it "should share thread_safe option across all objects" do
obj2 = SynchronizeSpecHelper.new
Redistat::Synchronize.thread_safe.should be_false
@obj.thread_safe.should be_false
obj2.thread_safe.should be_false
@obj.thread_safe = true
Redistat::Synchronize.thread_safe.should be_true
@obj.thread_safe.should be_true
obj2.thread_safe.should be_true
end
it "should not synchronize when thread_safe is disabled" do
# monitor receives :synchronize twice cause #thread_safe is _always_ synchronized
Redistat::Synchronize.monitor.should_receive(:synchronize).twice
@obj.thread_safe.should be_false # first #synchronize call
@obj.synchronize { 'foo' } # one #synchronize call while checking #thread_safe
end
it "should synchronize when thread_safe is enabled" do
Monitor.class_eval {
# we're stubbing synchronize to ensure it's being called correctly, but still need it :P
alias :real_synchronize :synchronize
}
Redistat::Synchronize.monitor.should_receive(:synchronize).with.exactly(4).times.and_return { |block|
Redistat::Synchronize.monitor.real_synchronize(&block)
}
@obj.thread_safe.should be_false # first synchronize call
Redistat::Synchronize.thread_safe = true # second synchronize call
@obj.synchronize { 'foo' } # two synchronize calls, once while checking thread_safe, once to call black
end
end
end
class SynchronizeSpecHelper class SynchronizeSpecHelper
include Redistat::Synchronize include Redistat::Synchronize