visit-counter-omri 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in visit-counter.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Tom Caspy
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,70 @@
1
+ # VisitCounter
2
+
3
+ [![Build Status](https://secure.travis-ci.org/KensoDev/visit-counter.png)](https://secure.travis-ci.org/KensoDev/visit-counter)
4
+
5
+ VisitCounter is a gem which solves the annoying problem of counting visits and displaying them in real time. In an SQL database, for a site with a lot of hits, this can cause quite a lot of overhead. VisitCounter aims to solve this by using a quick key-value store to keep a delta, and only persist to the SQL DB when the delta crosses a certain percent of the saved counter.
6
+ It can be used transparently, by overriding the accessor to the counter, or simply by using the helper functions it defines - incr_counter, read_counter, get_counter_delta and nullify_counter.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ gem 'visit-counter'
13
+
14
+ And then execute:
15
+
16
+ $ bundle
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install visit-counter
21
+
22
+ ## Usage
23
+
24
+ a. the default storage engine is redis. If you have a global $redis for your redis connection, we default to using that. Otherwise, or if you want to specify a different connection, in an initializer you should define it by:
25
+
26
+ VisitCounter::Store::RedisStore.redis = Redis.new(host: "your_redis_host", port: port)
27
+
28
+ b. in the class you wish to have a visit counter simply declare
29
+ include VisitCounter
30
+ from this moment on, you can use the incr_counter(:counter_name), nullify_counter(:counter_name) and read_counter(:counter_name) methods
31
+ You can also do something like this:
32
+
33
+ class Foo < ActiveRecord::Base
34
+ include VisitCounter
35
+ cached_counter :counter_name
36
+ end
37
+
38
+ this will override the counter_name method to read the live counter (from both database and the NoSQL storage) and add a increase_counter_name method for upping the counter by 1 (in the NoSQL and/or persist to DB when needed)
39
+
40
+ ##Thresholds
41
+
42
+ the default behaviour of visit counter is that once the visits pass 30% of the staged number, the visit counter stages the changes and nullifies the delta. You can, however, tweak that method. including VisitCounter in a class creates two class attribute_accessors, one named visit_counter_threshold_method, the other visit_counter_threshold.
43
+ visit_counter_threshold_method accepts either :static or :percent (the default), the threshold is either the decimal percent (0.1 for 10%) or an integer for :static. in case of the :static method you will percist to the DB once every (threshold) times the counter goes up.
44
+
45
+ so you might want to do something like that:
46
+
47
+ class Foo < ActiveRecord::Base
48
+ include VisitCounter
49
+ cached_counter :counter_name
50
+ self.visit_counter_threshold_method = :static
51
+ self.visit_counter_threshold = 100
52
+ end
53
+
54
+ if you want the counter to persist to database once every 100 views.
55
+
56
+ if you need to run callbacks for whatever reason after you update the counter just:
57
+
58
+ class Foo < ActiveRecord::Base
59
+ include VisitCounter
60
+ cached_counter :counter_name
61
+ self.update_callback_method = :update_something
62
+ end
63
+
64
+ ## Contributing
65
+
66
+ 1. Fork it
67
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
68
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
69
+ 4. Push to the branch (`git push origin my-new-feature`)
70
+ 5. Create new Pull Request
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require 'rspec/core'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |spec|
7
+ spec.pattern = FileList['spec/**/*_spec.rb']
8
+ end
9
+
10
+ task :default => :spec
@@ -0,0 +1,7 @@
1
+ require "visit-counter/version"
2
+ require "visit-counter/key"
3
+ require "visit-counter/visit_counter"
4
+ require "visit-counter/store/abstract_store"
5
+ require "visit-counter/store/redis_store"
6
+ require "visit-counter/store/rails_store"
7
+ require "visit-counter/store"
@@ -0,0 +1,7 @@
1
+ module VisitCounter
2
+ class Key
3
+ def self.key(object, method)
4
+ "visit_counter::#{object.class.to_s}::#{object.id}::#{method}"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ module VisitCounter
2
+ class Store
3
+ @@engine ||= VisitCounter::Store::RedisStore
4
+
5
+ class << self
6
+
7
+ def set_engine(store)
8
+ @@engine = store
9
+ end
10
+
11
+ def engine
12
+ @@engine
13
+ end
14
+
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ module VisitCounter
2
+ class Store
3
+ class AbstractStore
4
+ class << self
5
+ #placeholders
6
+
7
+ def incr(key)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def get(key)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def nullify(key)
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ module VisitCounter
2
+ class Store
3
+ class RailsStore < VisitCounter::Store::AbstractStore
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,66 @@
1
+ require "redis"
2
+
3
+ module VisitCounter
4
+ class Store
5
+ class RedisStore < VisitCounter::Store::AbstractStore
6
+ @@redis = nil
7
+ class << self
8
+
9
+ def redis=(r)
10
+ if r.is_a?(Redis)
11
+ @@redis = r
12
+ else
13
+ @@redis = Redis.new(r)
14
+ end
15
+ end
16
+
17
+ def redis
18
+ if @@redis.nil? && defined?($redis)
19
+ @@redis = $redis
20
+ else
21
+ @@redis
22
+ end
23
+ end
24
+
25
+ def incr(key)
26
+ redis.incr(key).to_i
27
+ end
28
+
29
+ def get(key)
30
+ redis.get(key).to_i
31
+ end
32
+
33
+ def nullify(key)
34
+ redis.set(key, 0)
35
+ end
36
+
37
+ def substract(key, by)
38
+ redis.decrby(key, by)
39
+ end
40
+
41
+ def acquire_lock(object)
42
+ redis.setnx(lock_key(object), 1)
43
+ end
44
+
45
+ def unlock!(object)
46
+ redis.del(lock_key(object))
47
+ end
48
+
49
+ def lock_key(object)
50
+ "#{object.class.name.downcase}_#{object.id}_object_cache_lock"
51
+ end
52
+
53
+ def with_lock(object, &block)
54
+ if acquire_lock(object)
55
+ begin
56
+ yield
57
+ ensure
58
+ unlock!(object)
59
+ end
60
+ end
61
+ end
62
+
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,3 @@
1
+ module VisitCounter
2
+ VERSION = "0.1.1"
3
+ end
@@ -0,0 +1,94 @@
1
+ module VisitCounter
2
+
3
+ def self.included(base)
4
+ base.class_eval do
5
+ class << self
6
+ #defining class instance attributes
7
+ attr_accessor :visit_counter_threshold, :visit_counter_threshold_method, :update_callback_method
8
+ end
9
+ end
10
+ base.send(:include, InstanceMethods)
11
+ base.send(:extend, ClassMethods)
12
+ end
13
+
14
+ module InstanceMethods
15
+ def incr_counter(name)
16
+ key = VisitCounter::Key.key(self, name)
17
+ count = VisitCounter::Store.engine.incr(key)
18
+ staged_count = self.send(:read_attribute, name).to_i
19
+ if Helper.passed_limit?(self, staged_count, count, name)
20
+ Helper.persist(self, staged_count, count, name)
21
+ end
22
+ end
23
+
24
+ def get_counter_delta(name)
25
+ key = VisitCounter::Key.key(self, name)
26
+ VisitCounter::Store.engine.get(key)
27
+ end
28
+
29
+ def read_counter(name)
30
+ current_count = self.send(:read_attribute, name).to_i
31
+ count = get_counter_delta(name)
32
+
33
+ current_count + count
34
+ end
35
+
36
+ def nullify_counter_cache(name, substract = nil)
37
+ key = VisitCounter::Key.key(self, name)
38
+ if substract
39
+ VisitCounter::Store.engine.substract(key, substract)
40
+ else
41
+ VisitCounter::Store.engine.nullify(key)
42
+ end
43
+ end
44
+ end
45
+
46
+ module ClassMethods
47
+
48
+ def cached_counter(name)
49
+
50
+ self.send(:define_method, name) do
51
+ read_counter(name)
52
+ end
53
+
54
+ self.send(:define_method, "increase_#{name}") do
55
+ incr_counter(name)
56
+ end
57
+ end
58
+ end
59
+
60
+ class Helper
61
+ class << self
62
+ def passed_limit?(object, staged_count, diff, name)
63
+ method = object.class.visit_counter_threshold_method || :percent
64
+ threshold = object.class.visit_counter_threshold || default_threshold(method)
65
+
66
+ if method.to_sym == :static
67
+ diff >= threshold
68
+ elsif method.to_sym == :percent
69
+ return true if staged_count.to_i == 0
70
+ diff.to_f / staged_count.to_f >= threshold
71
+ end
72
+ end
73
+
74
+ def persist(object, staged_count, diff, name)
75
+ VisitCounter::Store.engine.with_lock(object) do
76
+ object.update_attribute(name, staged_count + diff)
77
+ if object.class.update_callback_method
78
+ object.send(object.class.update_callback_method)
79
+ end
80
+ object.nullify_counter_cache(name, diff)
81
+ end
82
+ end
83
+
84
+ def default_threshold(method)
85
+ if method.to_sym == :static
86
+ 10
87
+ elsif method.to_sym == :percent
88
+ 0.3
89
+ end
90
+ end
91
+
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,19 @@
1
+ require "spec_helper"
2
+
3
+ describe VisitCounter::Store do
4
+
5
+ describe "engine" do
6
+ it "should have the redis store by default" do
7
+ VisitCounter::Store.engine.should == VisitCounter::Store::RedisStore
8
+ end
9
+
10
+ it "should be able to change the storage engine" do
11
+ VisitCounter::Store.set_engine(VisitCounter::Store::RailsStore)
12
+ VisitCounter::Store.engine.should == VisitCounter::Store::RailsStore
13
+
14
+ #switching back
15
+ VisitCounter::Store.set_engine(VisitCounter::Store::RedisStore)
16
+ end
17
+ end
18
+
19
+ end
@@ -0,0 +1,175 @@
1
+ require "spec_helper"
2
+
3
+ class DummyObject
4
+ include VisitCounter
5
+
6
+ attr_accessor :counter, :update_callback_method
7
+
8
+ def update_attribute(attribute, value)
9
+ self.send("#{attribute}=", value)
10
+ end
11
+
12
+ def update_something
13
+ nil
14
+ end
15
+
16
+ def read_attribute(name)
17
+ #yeah, evals are evil, but it works and it's for testing purposes only. we assume read_attribute is defined the same as in AR wherever we include this module
18
+ eval("@#{name}")
19
+ end
20
+ end
21
+
22
+ VisitCounter::Store::RedisStore.redis = Redis.new(host: "localhost")
23
+ VisitCounter::Store::RedisStore.redis.flushdb
24
+
25
+ describe VisitCounter do
26
+ describe "incrementing counters" do
27
+ before :each do
28
+ @d = DummyObject.new
29
+ @d.stub!(:id).and_return(1)
30
+ @d.nullify_counter_cache(:counter)
31
+ end
32
+
33
+ it "should increase the counter from nil / zero using the incr_counter method" do
34
+ @d.counter.should be_nil
35
+ @d.incr_counter(:counter)
36
+ @d.counter.should == 1
37
+ end
38
+
39
+ it "should not increase the counter if not passing the threshold" do
40
+ @d.counter = 100
41
+ @d.incr_counter(:counter)
42
+ @d.counter.should == 100
43
+ 30.times do
44
+ @d.incr_counter(:counter)
45
+ end
46
+ @d.counter.should == 130
47
+
48
+ #should still be 143, because of the percentage thingie
49
+ 30.times do
50
+ @d.incr_counter(:counter)
51
+ end
52
+ @d.counter.should == 130
53
+ end
54
+ end
55
+
56
+ describe "reading counters" do
57
+
58
+ before :each do
59
+ @d = DummyObject.new
60
+ @d.stub!(:id).and_return(1)
61
+ @d.nullify_counter_cache(:counter)
62
+ end
63
+
64
+ it "should allow to read an unchanged counter" do
65
+ @d.counter = 10
66
+ @d.read_counter(:counter).should == 10
67
+ end
68
+
69
+ it "should read an updated counter" do
70
+ @d.counter = 10
71
+ @d.incr_counter(:counter)
72
+ @d.read_counter(:counter).should == 11
73
+ end
74
+ end
75
+
76
+ describe "static threshold" do
77
+ before :each do
78
+ @d = DummyObject.new
79
+ @d.stub!(:id).and_return(1)
80
+ @d.nullify_counter_cache(:counter)
81
+ DummyObject.visit_counter_threshold_method = :static
82
+ end
83
+
84
+ it "should persist after setting a static threshold of 1" do
85
+ DummyObject.visit_counter_threshold = 1
86
+ @d.counter = 10
87
+ @d.incr_counter(:counter)
88
+ @d.counter.should == 11
89
+ end
90
+
91
+ it "should not persist after setting a static threshold of 2" do
92
+ DummyObject.visit_counter_threshold = 2
93
+ @d.counter = 10
94
+ @d.incr_counter(:counter)
95
+ @d.counter.should == 10
96
+
97
+ @d.incr_counter(:counter)
98
+ @d.counter.should == 12
99
+ end
100
+ end
101
+
102
+ describe "percent threshold" do
103
+ before :each do
104
+ @d = DummyObject.new
105
+ @d.stub!(:id).and_return(1)
106
+ @d.nullify_counter_cache(:counter)
107
+ DummyObject.visit_counter_threshold_method = :percent
108
+ end
109
+
110
+ it "should persist when passing a percent" do
111
+ DummyObject.visit_counter_threshold = 0.1
112
+ @d.counter = 10
113
+ @d.incr_counter(:counter)
114
+ @d.counter.should == 11
115
+ end
116
+ end
117
+
118
+ describe "locked objects" do
119
+ before :each do
120
+ @d = DummyObject.new
121
+ @d.stub!(:id).and_return(1)
122
+ @d.nullify_counter_cache(:counter)
123
+ end
124
+
125
+ it "should lock object when updating" do
126
+ VisitCounter::Store.engine.should_receive(:with_lock)
127
+ VisitCounter::Helper.persist(@d, 1, 1, :counter)
128
+ end
129
+
130
+ it "should not persist if object is locked" do
131
+ VisitCounter::Store.engine.stub!(:acquire_lock).and_return(false)
132
+ @d.should_not_receive(:update_attribute)
133
+ VisitCounter::Helper.persist(@d, 1, 1, :counter)
134
+ end
135
+
136
+ it "should persist if object is locked" do
137
+ VisitCounter::Store.engine.stub!(:acquire_lock).and_return(true)
138
+ @d.should_receive(:update_attribute)
139
+ VisitCounter::Helper.persist(@d, 1, 1, :counter)
140
+ end
141
+ end
142
+
143
+ describe "overriding the getter" do
144
+ before :all do
145
+ DummyObject.cached_counter :counter
146
+ end
147
+
148
+ before :each do
149
+ @d = DummyObject.new
150
+ @d.stub!(:id).and_return(1)
151
+ @d.nullify_counter_cache(:counter)
152
+ end
153
+
154
+ it "should define the methods" do
155
+ @d.should respond_to(:increase_counter)
156
+ end
157
+
158
+ it "should set the counter" do
159
+ @d.counter = 10
160
+ @d.increase_counter
161
+ @d.counter.should == 11
162
+ end
163
+ end
164
+
165
+ describe "persist with callbacks" do
166
+ it "should use update_something method" do
167
+ @d = DummyObject.new
168
+ @d.class.update_callback_method = :update_something
169
+ @d.stub!(:id).and_return(1)
170
+ @d.should_receive(:update_something)
171
+ @d.incr_counter :counter
172
+ end
173
+ end
174
+
175
+ end
@@ -0,0 +1,9 @@
1
+ require 'rubygems'
2
+ require "visit-counter"
3
+
4
+ RSpec.configure do |config|
5
+ # config.mock_with :mocha
6
+ # config.mock_with :flexmock
7
+ # config.mock_with :rr
8
+ config.mock_with :rspec
9
+ end
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/visit-counter/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Tom Caspy"]
6
+ gem.email = ["tcaspy@gmail.com"]
7
+ gem.description = %q{Simple counter increment which only writes to DB once in a while}
8
+ gem.summary = %q{No need to write to db each visit, save the visits to a quick DB like redis or memcached, and write to the SQL db once reads exeeded a certain threshold}
9
+ gem.homepage = "https://github.com/KensoDev/visit-counter"
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "visit-counter-omri"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = VisitCounter::VERSION
17
+
18
+ gem.add_development_dependency(%q<rspec>, [">= 0"])
19
+ gem.add_development_dependency(%q<rake>, ["~> 0.9.2"])
20
+ gem.add_dependency(%q<redis>, [">= 0"])
21
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: visit-counter-omri
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Tom Caspy
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-04-21 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ prerelease: false
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ none: false
23
+ type: :development
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ! '>='
27
+ - !ruby/object:Gem::Version
28
+ version: '0'
29
+ none: false
30
+ - !ruby/object:Gem::Dependency
31
+ name: rake
32
+ prerelease: false
33
+ requirement: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 0.9.2
38
+ none: false
39
+ type: :development
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ~>
43
+ - !ruby/object:Gem::Version
44
+ version: 0.9.2
45
+ none: false
46
+ - !ruby/object:Gem::Dependency
47
+ name: redis
48
+ prerelease: false
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ none: false
55
+ type: :runtime
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ none: false
62
+ description: Simple counter increment which only writes to DB once in a while
63
+ email:
64
+ - tcaspy@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - .gitignore
70
+ - Gemfile
71
+ - LICENSE
72
+ - README.md
73
+ - Rakefile
74
+ - lib/visit-counter.rb
75
+ - lib/visit-counter/key.rb
76
+ - lib/visit-counter/store.rb
77
+ - lib/visit-counter/store/abstract_store.rb
78
+ - lib/visit-counter/store/rails_store.rb
79
+ - lib/visit-counter/store/redis_store.rb
80
+ - lib/visit-counter/version.rb
81
+ - lib/visit-counter/visit_counter.rb
82
+ - spec/lib/store_spec.rb
83
+ - spec/lib/visit_counter_spec.rb
84
+ - spec/spec_helper.rb
85
+ - visit-counter.gemspec
86
+ homepage: https://github.com/KensoDev/visit-counter
87
+ licenses: []
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ! '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ none: false
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ! '>='
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ none: false
104
+ requirements: []
105
+ rubyforge_project:
106
+ rubygems_version: 1.8.24
107
+ signing_key:
108
+ specification_version: 3
109
+ summary: No need to write to db each visit, save the visits to a quick DB like redis
110
+ or memcached, and write to the SQL db once reads exeeded a certain threshold
111
+ test_files:
112
+ - spec/lib/store_spec.rb
113
+ - spec/lib/visit_counter_spec.rb
114
+ - spec/spec_helper.rb