74 Commits

Author SHA1 Message Date
8710f4a51f Merge branch 'release/v0.1.0' 2011-03-04 17:41:39 +00:00
96e9b0a736 Version bump to 0.1.0 2011-03-04 17:41:14 +00:00
102fb41a6b Merge branch 'feature/grouping' into dev 2011-03-04 17:40:17 +00:00
b0a44a6abc some more sanity checks to Label spec 2011-03-04 17:40:02 +00:00
f8dfb034af added label grouping to Key and Summary classes 2011-03-04 17:39:51 +00:00
15904e8a94 added grouping support to Redistat::Label 2011-03-04 16:25:31 +00:00
fe221c3f31 added enable_grouping option to disable grouping features, enabled by default 2011-03-04 13:02:20 +00:00
7b1feda061 added key grouping for statistics Hash
Example:
store(“message”, {“count/private” => 1})
store(“message”, {“count/public” => 1})
fetch("message", 2.minutes.ago, Time.now)
  #=> { "count" => 2,
        "count/private" => 1,
        "count/public" => 1 }
2011-03-04 12:54:50 +00:00
968ef47ac5 fixed name of SystemTimer gem in readme 2011-03-04 12:17:55 +00:00
e3c4a5da9a added Gemfile.lock to .gitignore file 2011-03-04 12:17:39 +00:00
b215c6d56c made rcov rake task work 2011-03-04 12:16:49 +00:00
8d5c73a539 Merge branch 'release/v0.0.9' into dev 2011-02-23 18:11:54 +00:00
0d5170bc26 Merge branch 'release/v0.0.9' 2011-02-23 18:11:50 +00:00
4692780d1e Version bump to 0.0.9 2011-02-23 18:11:15 +00:00
f8ec626762 removed Gemfile.lock from git repo (this is a gem
not an application ^_^)
2011-02-23 18:10:09 +00:00
ec54385192 updated activesupport dependency to ">= 2.3.6" as
redistat breaks with earlier versions
2011-02-23 18:09:19 +00:00
4808a97d19 added .rvmrc file for development ease 2011-02-23 18:08:21 +00:00
1dce2780e0 Merge branch 'Oscil8-master' into dev 2011-02-23 18:00:16 +00:00
Ariel Salomon
cab7ed5633 Fix for use with Active Support 2.3.x 2011-02-23 07:42:58 -08:00
861d040187 Merge branch 'release/v0.0.8' into dev 2011-01-12 16:13:18 +00:00
3267ee1eb9 Merge branch 'release/v0.0.8' 2011-01-12 16:13:13 +00:00
6309e4b217 Version bump to 0.0.8 2011-01-12 16:12:55 +00:00
776ee8ac97 make version available in code via
Redistat::VERSION
2011-01-12 16:11:19 +00:00
3b346e88e0 moved DateHelper module to it's own files for the
sake of transparency
2011-01-12 16:10:38 +00:00
c3fe861b10 connection handling was so thread-safe that it
stopped working in newly created threads
2011-01-12 16:04:42 +00:00
bc5034b6bb Merge branch 'release/v0.0.7' into dev 2010-12-29 17:29:20 +00:00
66510fe344 Merge branch 'release/v0.0.7' 2010-12-29 17:29:13 +00:00
745473862f added note about system_timer gem to readme 2010-12-29 17:28:56 +00:00
a5c8fc6fbf Version bump to 0.0.7 2010-12-29 17:27:19 +00:00
115b223d7c added class_name option to Model warpper for
customizing the scope used in Redis keys
2010-12-29 17:26:22 +00:00
0597b587fd Merge branch 'feature/ruby19' into dev 2010-12-29 17:14:40 +00:00
89932759ef Merge branch 'master' of https://github.com/JamesHarrison/redistat into feature/ruby19 2010-12-29 16:42:50 +00:00
55e0687837 Merge branch 'dev' into feature/ruby19 2010-12-29 16:42:36 +00:00
James Harrison
93360dbeb9 Specs pass again - problem with Time.now resolution surpassing that provided by values stored in a Redistat::Date, truncated to seconds by using to_s, which is accurate enough for testing purposes 2010-12-28 23:55:39 +00:00
James Harrison
6a66605e0b Adds Ruby 1.9.2 compat (references to TimeExt#round collide with the new real Time#now, changed to TimeExt#beginning_of_closest), 3 specs failing relating to time equality testing 2010-12-28 23:46:25 +00:00
James Harrison
d9ce0daade Removes SystemTimer for Ruby 1.9.2 compat 2010-12-28 22:11:33 +00:00
f6ec2e97b2 Merge branch 'dev' of github.com:jimeh/redistat into dev 2010-12-09 22:42:55 +00:00
67dc9433c7 fixed typo in deprecation warning 2010-12-09 22:42:27 +00:00
f0fcd2110d Merge branch 'release/v0.0.6' into dev 2010-12-01 13:41:48 +00:00
24112e4705 Merge branch 'release/v0.0.6' 2010-12-01 13:41:44 +00:00
b9752ff92f Version bump to 0.0.6 2010-12-01 13:41:30 +00:00
14a093d79b updated gem dependencies to less specific versions
as older versions should work
2010-12-01 13:40:55 +00:00
84a05363dd Merge branch 'release/v0.0.5' into dev 2010-11-28 11:56:23 +00:00
690d1d9407 Merge branch 'release/v0.0.5' 2010-11-28 11:56:15 +00:00
2aedd4eee3 Version bump to 0.0.5 2010-11-28 11:55:50 +00:00
f906cf068e added a spec for Collection#total 2010-11-28 11:52:23 +00:00
cbb9050c80 fixed a typo 2010-11-28 11:51:50 +00:00
58a2fb560c Merge branch 'feature/multi-conf' into dev 2010-11-28 11:48:27 +00:00
6bae8ce2bc Updated Connection spec so the specs actually pass
without a local Redis server running on the
default port of 6379.
2010-11-28 11:48:10 +00:00
18e6125c6a Added support for connection_ref's down throughout
the code, so models can connect to specific Redis
servers.

I believe a lot of the code needs some
restructuring at some point down the line to
handle multiple connections in a cleaner way, but
for now it'll do.
2010-11-28 11:47:26 +00:00
dc162e0c89 initial work to being able to use per-model redis
configurations
2010-11-28 10:10:58 +00:00
5338676a5f moved all dependencies to gemspec 2010-11-25 22:48:57 +00:00
02fe41082a Merge branch 'release/v0.0.4' into dev 2010-11-24 13:51:13 +00:00
65e7745419 Merge branch 'release/v0.0.4' 2010-11-24 13:51:03 +00:00
490356ee96 Bumped version to 0.0.4 2010-11-24 13:50:54 +00:00
a6c4600aa5 Merge branch 'feature/new_gemspec' into dev 2010-11-24 13:49:51 +00:00
85ba61b2cc removed jeweler's VERSION file 2010-11-24 13:48:48 +00:00
8f6a4a6820 fixed a typo 2010-11-24 13:47:51 +00:00
81ee2ec0b6 added console rake task 2010-11-24 13:46:11 +00:00
20280f2c5d switched to using bundle's own gemspec format
instead of jeweler
2010-11-24 13:46:03 +00:00
bf29696c46 Merge branch 'release/v0.0.3' into dev 2010-11-24 00:38:38 +00:00
92375b229a Merge branch 'release/v0.0.3' 2010-11-24 00:38:32 +00:00
e362c93d9a Version bump to 0.0.3 2010-11-24 00:38:19 +00:00
0f5b7449b0 updated readme 2010-11-24 00:37:28 +00:00
ea732b4734 updated gemfile and rakefile 2010-11-24 00:37:10 +00:00
62c3492c93 some whitespace cleanup 2010-11-24 00:36:44 +00:00
c5f52455cc replaced Redistat::Model#find alias to #fetch with
#lookup instead to avoid conflicts with
ActiveRecord
2010-11-24 00:36:25 +00:00
1226f8b89a Merge branch 'release/v0.0.2' into dev 2010-11-22 13:30:19 +00:00
bc7a563f20 Merge branch 'release/v0.0.2' 2010-11-22 13:30:14 +00:00
39da4c96a7 Version bump to 0.0.2 2010-11-22 13:29:56 +00:00
fa7903dc7a added gemspec to .gitignore 2010-11-22 13:29:41 +00:00
8a0e1a47a2 added proper support for hashed_label option, and
disabled it by default
2010-11-22 13:28:19 +00:00
8e3e54a027 updated gemspec description in Rakefile 2010-11-22 12:36:14 +00:00
07ae8b5c78 Merge branch 'release/v0.0.1' into dev 2010-11-22 12:31:45 +00:00
37 changed files with 710 additions and 282 deletions

8
.gitignore vendored
View File

@@ -16,10 +16,12 @@ tmtags
## PROJECT::GENERAL
coverage
rdoc
pkg
pkg/*
*.gem
.bundle
Gemfile.lock
## PROJECT::SPECIFIC
.bundle/*
.yardoc/*
spec/db/*
doc
doc/*

1
.rvmrc Normal file
View File

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

12
Gemfile
View File

@@ -1,12 +1,4 @@
source 'http://rubygems.org/'
gem 'activesupport', '>= 2.3.0'
gem 'json', '>= 1.0.0'
gem 'redis', '>= 2.0.0'
gem 'time_ext', '>= 0.2.6'
group :development do
gem 'rspec', '>= 2.0.1'
gem 'yard', '>= 0.6.1'
gem 'i18n'
end
# Specify your gem's dependencies in redistat.gemspec
gemspec

View File

@@ -1,31 +0,0 @@
GEM
remote: http://rubygems.org/
specs:
activesupport (3.0.3)
diff-lcs (1.1.2)
i18n (0.4.2)
json (1.4.6)
redis (2.1.1)
rspec (2.1.0)
rspec-core (~> 2.1.0)
rspec-expectations (~> 2.1.0)
rspec-mocks (~> 2.1.0)
rspec-core (2.1.0)
rspec-expectations (2.1.0)
diff-lcs (~> 1.1.2)
rspec-mocks (2.1.0)
time_ext (0.2.6)
activesupport (>= 2.3.0)
yard (0.6.3)
PLATFORMS
ruby
DEPENDENCIES
activesupport (>= 2.3.0)
i18n
json (>= 1.0.0)
redis (>= 2.0.0)
rspec (>= 2.0.1)
time_ext (>= 0.2.6)
yard (>= 0.6.1)

View File

@@ -2,11 +2,68 @@
A Redis-backed statistics storage and querying library written in Ruby.
## Early Beta
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:
Currently this is an early beta release. Readme and documentation is forthcoming.
* Store and increment/decrement integer values (counters, etc)
* Up to the second statistics available at all times
* Screamingly fast
For now, please check `spec/model_spec.rb` and `spec/model_helper.rb` to get started with how to use Redistat.
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
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.
## Usage
The simplest way to use Redistat is through the model wrapper.
class VisitorStats
include Redistat::Model
end
Before any of you Rails-purists start complaining about the model name being plural, I want to point out that it makes sense with Redistat, cause a model doesn't exactly return a specific row or object. But I'm getting ahead of myself.
To store statistics we essentially tell Redistat that an event has occurred with a label of X, and statistics of Y. So let's say we want to store a page view event on the `/about` page on a site:
VisitorStats.store('/about', {:views => 1})
In the above case "`/about`" is the label under which the stats are grouped, and the statistics associated with the event is simply a normal Ruby hash, except all values need to be integers, or Redis' increment calls won't work.
To later retrieve statistics, we use the `fetch` method:
stats = VisitorStats.fetch('/about', 2.hour.ago, Time.now)
# stats => [{:views => 1}]
# stats.total => {:views => 1}
The fetch method requires 3 arguments, a label, a start time, and an end time. Fetch returns a `Redistat::Collection` object, which is normal Ruby Array with a couple of added methods, like total shown above.
For more detailed usage, please check spec files, and source code till I have time to write up a complete readme.
## Some Technical Details
To give a brief look into how Redistat works internally to store statistics, I'm going to use the examples above. The store method accepts a Ruby Hash with statistics to store. Redistat stores all statistics as hashes in Redis. It stores summaries of the stats for the specific time when it happened and all it's parent time groups (second, minute, hour, day, month, year). The default depth Redistat goes to is hour, unless the `depth` option is passed to `store` or `fetch`.
In short, the above call to `store` creates the following keys in Redis:
VisitorStats//about:2010
VisitorStats//about:201011
VisitorStats//about:20101124
VisitorStats//about:2010112401
Each of these keys in Redis are a hash, containing the sums of each statistic point reported for the time frame the key represents. In this case there's two slashes, cause the label we used was “`/about`”, and the scope (class name when used through the model wrapper) and the label are separated with a slash.
When retrieving statistics for a given date range, Redistat figures out how to do the least number of calls to Redis to fetch all relevant data. For example, if you want the sum of stats from the 4th till the last of November, the full month of November would first be fetched, then the first 3 days of November would be fetched and removed from the full month stats.
## Todo
* Proper/complete readme.
* Documentation.
* Anything else that becomes apparent after real-world use.
## Note on Patches/Pull Requests

View File

@@ -1,29 +1,11 @@
require 'rubygems'
require 'rake'
begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = 'redistat'
gem.summary = 'TODO: one-line summary of your gem'
gem.description = 'TODO: longer description of your gem'
gem.email = 'contact@jimeh.me'
gem.homepage = 'http://github.com/jimeh/redistat'
gem.authors = ['Jim Myhrberg']
gem.add_dependency 'activesupport', '>= 2.3.0'
gem.add_dependency 'json', '>= 1.0.0'
gem.add_dependency 'redis', '>= 2.0.0'
gem.add_dependency 'time_ext', '>= 0.2.6'
gem.add_development_dependency 'rspec', '>= 2.0.1'
gem.add_development_dependency 'yard', '>= 0.6.1'
end
Jeweler::GemcutterTasks.new
rescue LoadError
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
end
require 'bundler'
Bundler::GemHelper.install_tasks
#
# Rspec
#
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec) do |spec|
spec.pattern = 'spec/**/*_spec.rb'
@@ -32,14 +14,16 @@ end
RSpec::Core::RakeTask.new(:rcov) do |spec|
spec.pattern = 'spec/**/*_spec.rb'
spec.rcov = true
spec.rcov_opts = ['--exclude', 'spec']
end
task :spec => :check_dependencies
task :default => [:start, :spec, :stop]
#
# Start/stop Redis test server
#
REDIS_DIR = File.expand_path(File.join("..", "spec"), __FILE__)
REDIS_CNF = File.join(REDIS_DIR, "redis-test.conf")
REDIS_PID = File.join(REDIS_DIR, "db", "redis.pid")
@@ -60,7 +44,10 @@ task :stop do
end
# YARD Documentation
#
# Yard
#
begin
require 'yard'
YARD::Rake::YardocTask.new
@@ -69,3 +56,14 @@ rescue LoadError
abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
end
end
#
# Misc.
#
desc "Start an irb console with Redistat pre-loaded."
task :console do
exec "irb -r spec/spec_helper"
end
task :c => :console

View File

@@ -1 +0,0 @@
0.0.1

View File

@@ -1,7 +1,7 @@
require 'rubygems'
require 'active_support'
require 'active_support/hash_with_indifferent_access' if !Hash.respond_to?(:with_indifferent_access) # Active Support 2.x and 3.x
require 'active_support/hash_with_indifferent_access' if !{}.respond_to?(:with_indifferent_access) # Active Support 2.x and 3.x
require 'redis'
require 'date'
require 'time'
@@ -10,8 +10,10 @@ require 'json'
require 'digest/sha1'
require 'redistat/collection'
require 'redistat/connection'
require 'redistat/database'
require 'redistat/date'
require 'redistat/date_helper'
require 'redistat/event'
require 'redistat/finder'
require 'redistat/finder/date_set'
@@ -21,10 +23,12 @@ require 'redistat/model'
require 'redistat/result'
require 'redistat/scope'
require 'redistat/summary'
require 'redistat/version'
require 'redistat/core_ext/date'
require 'redistat/core_ext/time'
require 'redistat/core_ext/fixnum'
require 'redistat/core_ext/bignum'
module Redistat
@@ -32,73 +36,31 @@ module Redistat
KEY_EVENT = ".event:"
KEY_LEBELS = "Redistat.lables:"
KEY_EVENT_IDS = ".event_ids"
GROUP_SEPARATOR = "/"
class InvalidOptions < ArgumentError; end
# Provides access to the Redis database. This is shared accross all models and instances.
def redis
threaded[:redis] ||= connection(*options)
end
def redis=(connection)
threaded[:redis] = connection
end
def threaded
Thread.current[:redistat] ||= {}
end
# Connect to a redis database.
#
# @param options [Hash] options to create a message with.
# @option options [#to_s] :host ('127.0.0.1') Host of the redis database.
# @option options [#to_s] :port (6379) Port number.
# @option options [#to_s] :db (0) Database number.
# @option options [#to_s] :timeout (0) Database timeout in seconds.
# @example Connect to a database in port 6380.
# Redistat.connect(:port => 6380)
def connect(*options)
self.redis = nil
@options = options
end
# Return a connection to Redis.
#
# This is a wapper around Redis.new(options)
def connection(*options)
Redis.new(*options)
end
def options
@options = [] unless defined? @options
@options
end
# Clear the database.
def flush
redis.flushdb
end
class RedisServerIsTooOld < Exception; end
module_function :connect, :connection, :flush, :redis, :redis=, :options, :threaded
class << self
def connection(ref = nil)
Connection.get(ref)
end
alias :redis :connection
def connection=(connection)
Connection.add(connection)
end
alias :redis= :connection=
def connect(options)
Connection.create(options)
end
def flush
puts "WARNING: Redistat.flush is deprecated. Use Redistat.redis.flushdb instead."
connection.flushdb
end
end
end

View File

@@ -12,5 +12,9 @@ module Redistat
@depth = options[:depth] ||= nil
end
def total
@total ||= {}
end
end
end

View File

@@ -0,0 +1,64 @@
module Redistat
module Connection
REQUIRED_SERVER_VERSION = "1.3.10"
class << self
def get(ref = nil)
ref ||= :default
connections[references[ref]] || create
end
def add(conn, ref = nil)
ref ||= :default
check_redis_version(conn)
references[ref] = conn.client.id
connections[conn.client.id] = conn
end
def create(options = {})
#TODO clean/remove all ref-less connections
ref = options.delete(:ref) || :default
options.reverse_merge!(default_options)
conn = (connections[connection_id(options)] ||= connection(options))
references[ref] = conn.client.id
conn
end
def connections
@connections ||= {}
end
def references
@references ||= {}
end
private
def check_redis_version(conn)
raise RedisServerIsTooOld if conn.info["redis_version"] < REQUIRED_SERVER_VERSION
conn
end
def connection(options)
check_redis_version(Redis.new(options))
end
def connection_id(options = {})
options.reverse_merge!(default_options)
"redis://#{options[:host]}:#{options[:port]}/#{options[:db]}"
end
def default_options
{
:host => '127.0.0.1',
:port => 6379,
:db => 0,
:timeout => 5
}
end
end
end
end

View File

@@ -0,0 +1,8 @@
class Bignum
include Redistat::DateHelper
def to_time
Time.at(self)
end
end

View File

@@ -3,8 +3,8 @@ module Redistat
def self.included(base)
base.extend(Database)
end
def db
Redistat.redis
def db(ref = nil)
Redistat.connection(ref)
end
end
end

View File

@@ -22,6 +22,8 @@ module Redistat
from_string(input)
elsif input.is_a?(::Fixnum)
from_integer(input)
elsif input.is_a?(::Bignum)
from_integer(input)
end
end
@@ -83,11 +85,4 @@ module Redistat
end
end
module DateHelper
def to_redistat(depth = nil)
Redistat::Date.new(self, depth)
end
alias :to_rs :to_redistat
end
end

View File

@@ -0,0 +1,8 @@
module Redistat
module DateHelper
def to_redistat(depth = nil)
Redistat::Date.new(self, depth)
end
alias :to_rs :to_redistat
end
end

View File

@@ -4,21 +4,37 @@ module Redistat
attr_reader :id
attr_reader :key
attr_reader :connection_ref
attr_accessor :stats
attr_accessor :meta
attr_accessor :options
def initialize(scope, label = nil, date = nil, stats = {}, options = {}, meta = {}, is_new = true)
@options = default_options.merge(options)
@options = parse_options(options)
@connection_ref = @options[:connection_ref]
@key = Key.new(scope, label, date, @options)
@stats = stats ||= {}
@meta = meta ||= {}
@new = is_new
end
def db
super(@connection_ref)
end
def parse_options(options)
default_options.each do |opt, val|
options[opt] = val if options[opt].nil?
end
options
end
def default_options
{ :depth => :hour, :store_event => false }
{ :depth => :hour,
:store_event => false,
:connection_ref => nil,
:enable_grouping => true }
end
def new?
@@ -59,7 +75,7 @@ module Redistat
def save
return false if !self.new?
Summary.update_all(@key, @stats, depth_limit)
Summary.update_all(@key, @stats, depth_limit, @connection_ref, @options[:enable_grouping])
if @options[:store_event]
@id = self.next_id
db.hmset("#{self.scope}#{KEY_EVENT}#{@id}",

View File

@@ -8,6 +8,10 @@ module Redistat
@options = options
end
def db
super(@options[:connection_ref])
end
def valid_options?
return true if !@options[:scope].blank? && !@options[:label].blank? && !@options[:from].blank? && !@options[:till].blank?
false

View File

@@ -42,7 +42,7 @@ module Redistat
return find_start_year_for(start_date, end_date, lowest_depth) if unit == :year
index = Date::DEPTHS.index(unit)
nunit = Date::DEPTHS[(index > 0) ? index-1 : index]
if start_date < start_date.round(nunit) || start_date.next(nunit).beginning_of(nunit) > end_date.beginning_of(nunit)
if start_date < start_date.beginning_of_closest(nunit) || start_date.next(nunit).beginning_of(nunit) > end_date.beginning_of(nunit)
add = []
start_date.beginning_of_each(unit, :include_start => lowest_depth).until(start_date.end_of(nunit)) do |t|
add << t.to_rs.to_s(unit) if t < end_date.beginning_of(unit)
@@ -59,7 +59,7 @@ module Redistat
index = Date::DEPTHS.index(unit)
nunit = Date::DEPTHS[(index > 0) ? index-1 : index]
has_nunit = end_date.prev(nunit).beginning_of(nunit) >= start_date.beginning_of(nunit)
nearest_nunit = end_date.round(nunit)
nearest_nunit = end_date.beginning_of_closest(nunit)
if end_date >= nearest_nunit && has_nunit
add = []
end_date.beginning_of(nunit).beginning_of_each(unit, :include_start => true, :include_end => lowest_depth).until(end_date) do |t|

View File

@@ -5,20 +5,20 @@ module Redistat
attr_accessor :date
attr_accessor :options
def initialize(scope, label = nil, date = nil, options = {})
def initialize(scope, label_name = nil, time_stamp = nil, options = {})
@options = default_options.merge(options || {})
@scope = scope
self.label = label if !label.nil?
self.date = date ||= Time.now
@options = default_options.merge(options ||= {})
self.label = label_name if !label_name.nil?
self.date = time_stamp ||= Time.now
end
def default_options
{ :depth => :day }
{ :depth => :hour, :hashed_label => false }
end
def prefix
key = "#{@scope}"
key << "/" + ((@options[:label_hash].nil? || @options[:label_hash] == true) ? @label.hash : @label.name) if !label.nil?
key << "/#{label}" if !label.nil?
key << ":"
key
end
@@ -39,8 +39,18 @@ module Redistat
@label.hash
end
def label_groups
@label.groups
end
def label=(input)
@label = (input.instance_of?(Redistat::Label)) ? input : Label.create(input)
@label = (input.instance_of?(Redistat::Label)) ? input : Label.create(input, @options)
end
def groups
@groups ||= label_groups.map do |label_name|
self.class.new(@scope, label_name, self.date, @options)
end
end
def to_s(depth = nil)

View File

@@ -2,16 +2,32 @@ module Redistat
class Label
include Database
attr_reader :name
attr_reader :hash
attr_reader :raw
attr_reader :connection_ref
def initialize(str)
@name = str.to_s
@hash = Digest::SHA1.hexdigest(@name)
def self.create(name, options = {})
self.new(name, options).save
end
def initialize(str, options = {})
@options = options
@raw = str.to_s
end
def db
super(@options[:connection_ref])
end
def name
@options[:hashed_label] ? hash : @raw
end
def hash
@hash ||= Digest::SHA1.hexdigest(@raw)
end
def save
@saved = (db.set("#{KEY_LEBELS}#{@hash}", @name) == "OK")
@saved = (db.set("#{KEY_LEBELS}#{hash}", @raw) == "OK") if @options[:hashed_label]
self
end
@@ -19,8 +35,18 @@ module Redistat
@saved ||= false
end
def self.create(name)
self.new(name).save
def groups
return @groups if @groups
@groups = []
parent = ""
@raw.split(GROUP_SEPARATOR).each do |part|
if !part.blank?
group = ((parent.blank?) ? "" : "#{parent}/") + part
@groups << group
parent = group
end
end
@groups.reverse!
end
end

View File

@@ -1,5 +1,6 @@
module Redistat
module Model
include Redistat::Database
def self.included(base)
base.extend(self)
@@ -10,6 +11,16 @@ module Redistat
end
alias :event :store
def connect_to(opts = {})
Connection.create(opts.merge(:ref => name))
options[:connection_ref] = name
end
def connection
db(options[:connection_ref])
end
alias :redis :connection
def fetch(label, from, till, opts = {})
Finder.find({
:scope => name,
@@ -18,7 +29,24 @@ module Redistat
:till => till
}.merge(options.merge(opts)))
end
alias :find :fetch
alias :lookup :fetch
def hashed_label(boolean = nil)
if !boolean.nil?
options[:hashed_label] = boolean
else
options[:hashed_label] || nil
end
end
def class_name(class_name = nil)
if !class_name.nil?
options[:class_name] = class_name
else
options[:class_name] || nil
end
end
alias :scope :class_name
def depth(depth = nil)
if !depth.nil?
@@ -43,7 +71,7 @@ module Redistat
private
def name
@name ||= self.to_s
options[:class_name] || (@name ||= self.to_s)
end
end

View File

@@ -1,5 +1,5 @@
module Redistat
class Result < ::ActiveSupport::HashWithIndifferentAccess
class Result < HashWithIndifferentAccess
attr_accessor :from
attr_accessor :till

View File

@@ -2,23 +2,57 @@ module Redistat
class Summary
include Database
def self.update_all(key, stats = {}, depth_limit = nil)
def self.update_all(key, stats = {}, depth_limit = nil, connection_ref = nil, enable_grouping = nil)
stats ||= {}
depth_limit ||= key.depth
return nil if stats.size == 0
Date::DEPTHS.each do |depth|
update(key, stats, depth)
break if depth == depth_limit
depth_limit ||= key.depth
enable_grouping = true if enable_grouping.nil?
if enable_grouping
stats = inject_group_summaries(stats)
key.groups.each { |k|
update_key(k, stats, depth_limit, connection_ref)
}
else
update_key(key, stats, depth_limit, connection_ref)
end
end
private
def self.update(key, stats, depth)
stats.each do |field, value|
db.hincrby key.to_s(depth), field, value
def self.update_key(key, stats, depth_limit, connection_ref)
Date::DEPTHS.each do |depth|
update(key, stats, depth, connection_ref)
break if depth == depth_limit
end
end
def self.update(key, stats, depth, connection_ref = nil)
stats.each do |field, value|
db(connection_ref).hincrby key.to_s(depth), field, value
end
end
def self.inject_group_summaries!(stats)
stats.each do |key, value|
parts = key.to_s.split(GROUP_SEPARATOR)
parts.pop
if parts.size > 0
sum_parts = []
parts.each do |part|
sum_parts << part
sum_key = sum_parts.join(GROUP_SEPARATOR)
(stats.has_key?(sum_key)) ? stats[sum_key] += value : stats[sum_key] = value
end
end
end
stats
end
def self.inject_group_summaries(stats)
inject_group_summaries!(stats.clone)
end
end
end

3
lib/redistat/version.rb Normal file
View File

@@ -0,0 +1,3 @@
module Redistat
VERSION = "0.1.0"
end

30
redistat.gemspec Normal file
View File

@@ -0,0 +1,30 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "redistat/version"
Gem::Specification.new do |s|
s.name = "redistat"
s.version = Redistat::VERSION
s.platform = Gem::Platform::RUBY
s.authors = ["Jim Myhrberg"]
s.email = ["contact@jimeh.me"]
s.homepage = "http://github.com/jimeh/redistat"
s.summary = %q{A Redis-backed statistics storage and querying library written in Ruby.}
s.description = %q{A Redis-backed statistics storage and querying library written in Ruby.}
s.rubyforge_project = "redistat"
s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]
s.add_runtime_dependency 'activesupport', '>= 2.3.6'
s.add_runtime_dependency 'json', '>= 1.4.0'
s.add_runtime_dependency 'redis', '>= 2.1.0'
s.add_runtime_dependency 'time_ext', '>= 0.2.8'
s.add_development_dependency 'rspec', '>= 2.1.0'
s.add_development_dependency 'rcov', '>= 0.9.9'
s.add_development_dependency 'yard', '>= 0.6.3'
end

View File

@@ -1,34 +0,0 @@
require "spec_helper"
describe Redistat do
include Redistat::Database
before(:each) do
db.flushdb
end
it "should have a valid Redis client instance" do
db.should_not be_nil
end
it "should be connected to the testing server" do
db.client.port.should == 8379
db.client.host.should == "127.0.0.1"
end
it "should be able to set and get data" do
db.set("hello", "world")
db.get("hello").should == "world"
db.del("hello").should be_true
end
it "should be able to store hashes to Redis" do
db.hset("key", "field", "1")
db.hget("key", "field").should == "1"
db.hincrby("key", "field", 1)
db.hget("key", "field").should == "2"
db.hincrby("key", "field", -1)
db.hget("key", "field").should == "1"
end
end

View File

@@ -2,7 +2,7 @@ require "spec_helper"
describe Redistat::Collection do
it "should should initialize properly" do
it "should initialize properly" do
options = {:from => "from", :till => "till", :depth => "depth"}
result = Redistat::Collection.new(options)
result.from.should == options[:from]
@@ -10,4 +10,11 @@ describe Redistat::Collection do
result.depth.should == options[:depth]
end
it "should have a total property" do
col = Redistat::Collection.new()
col.total.should == {}
col.total = {:foo => "bar"}
col.total.should == {:foo => "bar"}
end
end

61
spec/connection_spec.rb Normal file
View File

@@ -0,0 +1,61 @@
require "spec_helper"
include Redistat
describe Redistat::Connection do
it "should have a valid Redis client instance" do
Redistat.redis.should_not be_nil
end
it "should have initialized custom testing connection" do
redis = Redistat.redis
redis.client.host.should == '127.0.0.1'
redis.client.port.should == 8379
redis.client.db.should == 15
end
it "should be able to set and get data" do
redis = Redistat.redis
redis.set("hello", "world")
redis.get("hello").should == "world"
redis.del("hello").should be_true
end
it "should be able to store hashes to Redis" do
redis = Redistat.redis
redis.hset("hash", "field", "1")
redis.hget("hash", "field").should == "1"
redis.hincrby("hash", "field", 1)
redis.hget("hash", "field").should == "2"
redis.hincrby("hash", "field", -1)
redis.hget("hash", "field").should == "1"
redis.del("hash")
end
it "should be accessible from Redistat module" do
Redistat.redis.should == Connection.get
Redistat.redis.should == Redistat.connection
end
it "should handle multiple connections with refs" do
Redistat.redis.client.db.should == 15
Redistat.connect(:port => 8379, :db => 14, :ref => "Custom")
Redistat.redis.client.db.should == 15
Redistat.redis("Custom").client.db.should == 14
end
it "should be able to overwrite default and custom refs" do
Redistat.redis.client.db.should == 15
Redistat.connect(:port => 8379, :db => 14)
Redistat.redis.client.db.should == 14
Redistat.redis("Custom").client.db.should == 14
Redistat.connect(:port => 8379, :db => 15, :ref => "Custom")
Redistat.redis("Custom").client.db.should == 15
# Reset the default connection to the testing server or all hell
# might brake loose from the rest of the specs
Redistat.connect(:port => 8379, :db => 15)
end
end

10
spec/database_spec.rb Normal file
View File

@@ -0,0 +1,10 @@
require "spec_helper"
describe Redistat::Database do
include Redistat::Database
it "should make #db method available when included" do
db.should == Redistat.redis
end
end

View File

@@ -20,7 +20,7 @@ describe Redistat::Event do
@event.scope.should == @scope
@event.label.should == @label
@event.label_hash.should == @label_hash
@event.date.to_time.should == @date
@event.date.to_time.to_s.should == @date.to_s
@event.stats.should == @stats
@event.meta.should == @meta
@event.options.should == @event.default_options.merge(@options)
@@ -28,10 +28,10 @@ describe Redistat::Event do
it "should allow changing attributes" do
# date
@event.date.to_time.should == @date
@event.date.to_time.to_s.should == @date.to_s
@date = Time.now
@event.date = @date
@event.date.to_time.should == @date
@event.date.to_time.to_s.should == @date.to_s
# label
@event.label.should == @label
@event.label_hash.should == @label_hash

View File

@@ -101,12 +101,3 @@ describe Redistat::Finder do
end
end

View File

@@ -14,29 +14,30 @@ describe Redistat::Key do
@key.scope.should == @scope
@key.label.should == @label
@key.label_hash.should == @label_hash
@key.label_groups.should == @key.instance_variable_get("@label").groups
@key.date.should be_instance_of(Redistat::Date)
@key.date.to_time.to_s.should == @date.to_s
end
it "should convert to string properly" do
@key.to_s.should == "#{@scope}/#{@label_hash}:#{@key.date.to_s(:hour)}"
@key.to_s.should == "#{@scope}/#{@label}:#{@key.date.to_s(:hour)}"
props = [:year, :month, :day, :hour, :min, :sec]
props.each do
@key.to_s(props.last).should == "#{@scope}/#{@label_hash}:#{@key.date.to_s(props.last)}"
@key.to_s(props.last).should == "#{@scope}/#{@label}:#{@key.date.to_s(props.last)}"
props.pop
end
end
it "should abide to hash_label option" do
@key = Redistat::Key.new(@scope, @label, @date, {:depth => :hour, :label_hash => true})
it "should abide to hashed_label option" do
@key = Redistat::Key.new(@scope, @label, @date, {:depth => :hour, :hashed_label => true})
@key.to_s.should == "#{@scope}/#{@label_hash}:#{@key.date.to_s(:hour)}"
@key = Redistat::Key.new(@scope, @label, @date, {:depth => :hour, :label_hash => false})
@key = Redistat::Key.new(@scope, @label, @date, {:depth => :hour, :hashed_label => false})
@key.to_s.should == "#{@scope}/#{@label}:#{@key.date.to_s(:hour)}"
end
it "should have default depth option" do
@key = Redistat::Key.new(@scope, @label, @date)
@key.depth.should == :day
@key.depth.should == :hour
end
it "should allow changing attributes" do
@@ -46,10 +47,10 @@ describe Redistat::Key do
@key.scope = @scope
@key.scope.should == @scope
# date
@key.date.to_time.should == @date
@key.date.to_time.to_s.should == @date.to_s
@date = Time.now
@key.date = @date
@key.date.to_time.should == @date
@key.date.to_time.to_s.should == @date.to_s
# label
@key.label.should == @label
@key.label_hash == @label_hash
@@ -60,4 +61,16 @@ describe Redistat::Key do
@key.label_hash == @label_hash
end
it "should create a group of keys from label group" do
label = 'message/public/offensive'
result = [ "message/public/offensive",
"message/public",
"message" ]
key = Redistat::Key.new(@scope, label, @date, {:depth => :hour})
key.label_groups.should == result
key.groups.map { |k| k.label }.should == result
end
end

View File

@@ -15,14 +15,34 @@ describe Redistat::Label do
end
it "should store a label hash lookup key" do
@label.save
@label.saved?.should be_true
db.get("#{Redistat::KEY_LEBELS}#{@label.hash}").should == @name
label = Redistat::Label.new(@name, {:hashed_label => true}).save
label.saved?.should be_true
db.get("#{Redistat::KEY_LEBELS}#{label.hash}").should == @name
@name = "contact_us"
@label = Redistat::Label.create(@name)
@label.saved?.should be_true
db.get("#{Redistat::KEY_LEBELS}#{@label.hash}").should == @name
name = "contact_us"
label = Redistat::Label.create(name, {:hashed_label => true})
label.saved?.should be_true
db.get("#{Redistat::KEY_LEBELS}#{label.hash}").should == name
end
it "should separate label names into groups" do
name = "message/public/offensive"
label = Redistat::Label.new(name)
label.name.should == name
label.groups.should == [ "message/public/offensive",
"message/public",
"message" ]
name = "/message/public/"
label = Redistat::Label.new(name)
label.name.should == name
label.groups.should == [ "message/public",
"message" ]
name = "message"
label = Redistat::Label.new(name)
label.name.should == name
label.groups.should == [ "message" ]
end
end

View File

@@ -1,6 +1,6 @@
require "redistat"
class ModelHelper
class ModelHelper1
include Redistat::Model
@@ -11,5 +11,20 @@ class ModelHelper2
depth :day
store_event true
hashed_label true
end
class ModelHelper3
include Redistat::Model
connect_to :port => 8379, :db => 14
end
class ModelHelper4
include Redistat::Model
class_name "FancyHelper"
end

View File

@@ -5,56 +5,101 @@ describe Redistat::Model do
include Redistat::Database
before(:each) do
db.flushdb
ModelHelper1.redis.flushdb
ModelHelper2.redis.flushdb
ModelHelper3.redis.flushdb
ModelHelper4.redis.flushdb
end
it "should should name itself correctly" do
ModelHelper.send(:name).should == "ModelHelper"
ModelHelper1.send(:name).should == "ModelHelper1"
ModelHelper2.send(:name).should == "ModelHelper2"
end
it "should listen to model-defined options" do
ModelHelper2.depth.should == :day
ModelHelper2.store_event.should == true
ModelHelper2.hashed_label.should == true
ModelHelper2.class_name.should be_nil
ModelHelper.depth.should == nil
ModelHelper.store_event.should == nil
ModelHelper.depth(:hour)
ModelHelper.depth.should == :hour
ModelHelper.store_event(true)
ModelHelper.store_event.should == true
ModelHelper.options[:depth] = nil
ModelHelper.options[:store_event] = nil
ModelHelper.depth.should == nil
ModelHelper.store_event.should == nil
ModelHelper1.depth.should == nil
ModelHelper1.store_event.should == nil
ModelHelper1.hashed_label.should == nil
ModelHelper1.depth(:hour)
ModelHelper1.depth.should == :hour
ModelHelper1.store_event(true)
ModelHelper1.store_event.should == true
ModelHelper1.hashed_label(true)
ModelHelper1.hashed_label.should == true
ModelHelper1.options[:depth] = nil
ModelHelper1.options[:store_event] = nil
ModelHelper1.options[:hashed_label] = nil
ModelHelper1.depth.should == nil
ModelHelper1.store_event.should == nil
ModelHelper1.hashed_label.should == nil
ModelHelper4.class_name.should == "FancyHelper"
ModelHelper4.send(:name).should == "FancyHelper"
end
it "should store and fetch stats" do
ModelHelper.store("sheep.black", {:count => 6, :weight => 461}, 4.hours.ago)
ModelHelper.store("sheep.black", {:count => 2, :weight => 156})
ModelHelper1.store("sheep.black", {:count => 6, :weight => 461}, 4.hours.ago)
ModelHelper1.store("sheep.black", {:count => 2, :weight => 156})
stats = ModelHelper.fetch("sheep.black", 2.hours.ago, 1.hour.from_now)
stats = ModelHelper1.fetch("sheep.black", 2.hours.ago, 1.hour.from_now)
stats.total["count"].should == 2
stats.total["weight"].should == 156
stats.first.should == stats.total
stats = ModelHelper.fetch("sheep.black", 5.hours.ago, 1.hour.from_now)
stats = ModelHelper1.fetch("sheep.black", 5.hours.ago, 1.hour.from_now)
stats.total[:count].should == 8
stats.total[:weight].should == 617
stats.first.should == stats.total
ModelHelper.store("sheep.white", {:count => 5, :weight => 393}, 4.hours.ago)
ModelHelper.store("sheep.white", {:count => 4, :weight => 316})
ModelHelper1.store("sheep.white", {:count => 5, :weight => 393}, 4.hours.ago)
ModelHelper1.store("sheep.white", {:count => 4, :weight => 316})
stats = ModelHelper.fetch("sheep.white", 2.hours.ago, 1.hour.from_now)
stats = ModelHelper1.fetch("sheep.white", 2.hours.ago, 1.hour.from_now)
stats.total[:count].should == 4
stats.total[:weight].should == 316
stats.first.should == stats.total
stats = ModelHelper.fetch("sheep.white", 5.hours.ago, 1.hour.from_now)
stats = ModelHelper1.fetch("sheep.white", 5.hours.ago, 1.hour.from_now)
stats.total[:count].should == 9
stats.total[:weight].should == 709
stats.first.should == stats.total
end
end
it "should connect to different Redis servers on a per-model basis" do
ModelHelper3.redis.client.db.should == 14
ModelHelper3.store("sheep.black", {:count => 6, :weight => 461}, 4.hours.ago)
ModelHelper3.store("sheep.black", {:count => 2, :weight => 156})
db.keys("*").should be_empty
ModelHelper1.redis.keys("*").should be_empty
db("ModelHelper3").keys("*").should have(5).items
ModelHelper3.redis.keys("*").should have(5).items
stats = ModelHelper3.fetch("sheep.black", 2.hours.ago, 1.hour.from_now)
stats.total["count"].should == 2
stats.total["weight"].should == 156
stats = ModelHelper3.fetch("sheep.black", 5.hours.ago, 1.hour.from_now)
stats.total[:count].should == 8
stats.total[:weight].should == 617
ModelHelper3.connect_to(:port => 8379, :db => 13)
ModelHelper3.redis.client.db.should == 13
stats = ModelHelper3.fetch("sheep.black", 5.hours.ago, 1.hour.from_now)
stats.total.should == {}
ModelHelper3.connect_to(:port => 8379, :db => 14)
ModelHelper3.redis.client.db.should == 14
stats = ModelHelper3.fetch("sheep.black", 5.hours.ago, 1.hour.from_now)
stats.total[:count].should == 8
stats.total[:weight].should == 617
end
end

View File

@@ -8,5 +8,5 @@ require 'rspec'
require 'rspec/autorun'
# use the test Redistat instance
Redistat.connect({:port => 8379, :db => 15})
Redistat.flush
Redistat.connect(:port => 8379, :db => 15, :thread_safe => true)
Redistat.redis.flushdb

View File

@@ -46,27 +46,78 @@ describe Redistat::Summary do
end
end
it "should inject stats key grouping summaries" do
hash = { "count/hello" => 3, "count/world" => 7,
"death/bomb" => 4, "death/unicorn" => 3,
:"od/sugar" => 7, :"od/meth" => 8 }
res = Redistat::Summary.send(:inject_group_summaries, hash)
res.should == { "count" => 10, "count/hello" => 3, "count/world" => 7,
"death" => 7, "death/bomb" => 4, "death/unicorn" => 3,
"od" => 15, :"od/sugar" => 7, :"od/meth" => 8 }
end
it "should properly store key group summaries" do
stats = {"views" => 3, "visitors/eu" => 2, "visitors/us" => 4}
Redistat::Summary.update_all(@key, stats, :hour)
summary = db.hgetall(@key.to_s(:hour))
summary.should have(4).items
summary["views"].should == "3"
summary["visitors"].should == "6"
summary["visitors/eu"].should == "2"
summary["visitors/us"].should == "4"
end
it "should not store key group summaries when option is disabled" do
stats = {"views" => 3, "visitors/eu" => 2, "visitors/us" => 4}
Redistat::Summary.update_all(@key, stats, :hour, nil, false)
summary = db.hgetall(@key.to_s(:hour))
summary.should have(3).items
summary["views"].should == "3"
summary["visitors/eu"].should == "2"
summary["visitors/us"].should == "4"
end
it "should store label-based grouping enabled stats" do
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.should == "views/about_us"
key.groups[1].label.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.should == "views/contact"
key.groups[1].label.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"
end
end

View File

@@ -0,0 +1,39 @@
require "spec_helper"
describe "Thread-Safety" do
include Redistat::Database
before(:each) do
db.flushdb
end
#TODO should have more comprehensive thread-safe tests
it "should incr in multiple threads" do
threads = []
50.times do
threads << Thread.new {
db.incr("spec:incr")
}
end
threads.each { |t| t.join }
db.get("spec:incr").should == "50"
end
it "should store event in multiple threads" do
class ThreadSafetySpec
include Redistat::Model
end
threads = []
50.times do
threads << Thread.new {
ThreadSafetySpec.store("spec:threadsafe", {:count => 1, :rand => rand(5)})
}
end
threads.each { |t| t.join }
result = ThreadSafetySpec.fetch("spec:threadsafe", 5.hours.ago, 5.hours.from_now)
result.total[:count].should == 50
result.total[:rand].should <= 250
end
end