visit_counter_updater 0.0.1 → 0.0.2
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.
- data/lib/visit_counter_updater/key.rb +23 -0
- data/lib/visit_counter_updater/store/redis_store.rb +35 -0
- data/lib/visit_counter_updater/version.rb +1 -1
- data/lib/visit_counter_updater/visit_counter_updater.rb +72 -0
- data/spec/visit_counter_updater_spec.rb +60 -0
- data/visit_counter_updater.gemspec +3 -3
- metadata +10 -5
@@ -0,0 +1,23 @@
|
|
1
|
+
module VisitCounter
|
2
|
+
class Key
|
3
|
+
|
4
|
+
attr_reader :object, :method
|
5
|
+
def initialize(object, method)
|
6
|
+
@object = object
|
7
|
+
@method = method
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.generate(method, obj_class, obj_id = nil)
|
11
|
+
["visit_counter", obj_class, obj_id, method].compact.join("::")
|
12
|
+
end
|
13
|
+
|
14
|
+
def generate_from_instance
|
15
|
+
object_class = self.object_class
|
16
|
+
Key.generate(method,object_class, self.object.id)
|
17
|
+
end
|
18
|
+
|
19
|
+
def object_class
|
20
|
+
object.class.to_s
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require "redis"
|
2
|
+
|
3
|
+
module VisitCounter
|
4
|
+
class Store
|
5
|
+
class RedisStore < VisitCounter::Store::AbstractStore
|
6
|
+
class << self
|
7
|
+
## adding keys to sorted sets, to allow searching by timstamp(score).
|
8
|
+
## subsequent hits to the same key will only update the timestamp (and won't duplicate).
|
9
|
+
def incr(key, timestamp, set_name)
|
10
|
+
redis.zadd(set_name, timestamp, key)
|
11
|
+
redis.incr(key).to_i
|
12
|
+
end
|
13
|
+
|
14
|
+
def del(key)
|
15
|
+
redis.del(key)
|
16
|
+
end
|
17
|
+
|
18
|
+
def mget(keys)
|
19
|
+
redis.mget(*keys).map(&:to_i)
|
20
|
+
end
|
21
|
+
|
22
|
+
def mnullify(keys)
|
23
|
+
keys_with_0 = keys.flat_map {|k| [k,"0"]}
|
24
|
+
redis.mset(*keys_with_0)
|
25
|
+
end
|
26
|
+
|
27
|
+
## Usage: to get all post#num_reads counters in the last hour, do:
|
28
|
+
## redis.zrangebyscore("visit-counter::Post::num_reads", (Time.now - 3600).to_i, Time.now.to_i)
|
29
|
+
def get_all_by_range(sorted_set_key, min, max)
|
30
|
+
redis.zrangebyscore(sorted_set_key, min, max)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module VisitCounter
|
2
|
+
module InstanceMethods
|
3
|
+
def incr_counter(name)
|
4
|
+
key = VisitCounter::Key.new(self, name)
|
5
|
+
set_key = VisitCounter::Key.generate(key.method, key.object_class)
|
6
|
+
count = VisitCounter::Store.engine.incr(key.generate_from_instance, Time.now.to_i, set_key)
|
7
|
+
|
8
|
+
staged_count = self.send(:read_attribute, name).to_i
|
9
|
+
if Helper.passed_limit?(self, staged_count, count, name)
|
10
|
+
Helper.persist(self, staged_count, count, name)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def get_counter_delta(name)
|
15
|
+
key = VisitCounter::Key.new(self, name).generate_from_instance
|
16
|
+
VisitCounter::Store.engine.get(key)
|
17
|
+
end
|
18
|
+
|
19
|
+
def nullify_counter_cache(name, substract = nil)
|
20
|
+
key = VisitCounter::Key.new(self, name).generate_from_instance
|
21
|
+
if substract
|
22
|
+
VisitCounter::Store.engine.substract(key, substract)
|
23
|
+
else
|
24
|
+
VisitCounter::Store.engine.nullify(key)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
module ClassMethods
|
31
|
+
## override counter threshold. update method counters from the given Time
|
32
|
+
## E.g. Post.update_counters(:num_reads, 1.hour.ago) will update all items that received hits in the last hour
|
33
|
+
def update_counters(name, from_time_ago)
|
34
|
+
set_name = VisitCounter::Key.generate(name, self)
|
35
|
+
counters_in_timeframe = VisitCounter::Store.engine.get_all_by_range(set_name, from_time_ago.to_i, Time.now.to_i)
|
36
|
+
obj_ids = counters_in_timeframe.flat_map { |c| [c.split("::")[2].to_i] }
|
37
|
+
objects = self.where(id: obj_ids)
|
38
|
+
|
39
|
+
## if no objects corresponding to the counters were found, remove them.
|
40
|
+
counters = Helper.reject_objectless_counters(counters_in_timeframe, objects, obj_ids)
|
41
|
+
|
42
|
+
hits, staged_count = Helper.get_count_stats(counters, objects, name)
|
43
|
+
self.transaction do
|
44
|
+
objects.zip(Helper.merge_array_values(hits, staged_count)).each do |o|
|
45
|
+
VisitCounter::Store.engine.with_lock(o[0]) do
|
46
|
+
o[0].class.update_all("#{name} = #{o[1]}", "id = #{o[0].id}")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
VisitCounter::Store.engine.mnullify(counters)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Helper
|
55
|
+
class << self
|
56
|
+
def merge_array_values(a1,a2)
|
57
|
+
a1.zip(a2).map {|i| i.inject(&:+)}
|
58
|
+
end
|
59
|
+
|
60
|
+
def reject_objectless_counters(counters, objects, counter_obj_ids)
|
61
|
+
delete_counter_ids = counter_obj_ids - objects.compact.map(&:id)
|
62
|
+
counters.reject {|c| delete_counter_ids.include?(c[2])}
|
63
|
+
end
|
64
|
+
|
65
|
+
def get_count_stats(counters, objects, name)
|
66
|
+
hits = VisitCounter::Store.engine.mget(counters).map(&:to_i)
|
67
|
+
staged_count = objects.map {|o| o.send(:read_attribute, name).to_i}
|
68
|
+
return hits, staged_count
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
class DummyObject
|
4
|
+
include VisitCounter
|
5
|
+
|
6
|
+
attr_accessor :counter
|
7
|
+
|
8
|
+
def update_attribute(attribute, value)
|
9
|
+
self.send("#{attribute}=", value)
|
10
|
+
end
|
11
|
+
|
12
|
+
def read_attribute(name)
|
13
|
+
#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
|
14
|
+
eval("@#{name}")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
VisitCounter::Store::RedisStore.redis = Redis.new(host: "localhost")
|
19
|
+
VisitCounter::Store::RedisStore.redis.flushdb
|
20
|
+
|
21
|
+
describe VisitCounter do
|
22
|
+
describe "updating counters for the given time-period (disregarding threshold)" do
|
23
|
+
let(:d_key) {"visit_counter::DummyObject::1::counter"}
|
24
|
+
let(:d1_key) {"visit_counter::DummyObject::2::counter"}
|
25
|
+
let(:set_name) {"visit_counter::DummyObject::counter"}
|
26
|
+
|
27
|
+
before :each do
|
28
|
+
@d, @d1 = DummyObject.new, DummyObject.new
|
29
|
+
@d.stub(:id).and_return(1)
|
30
|
+
@d1.stub(:id).and_return(2)
|
31
|
+
DummyObject.stub(:transaction).and_yield
|
32
|
+
@d.nullify_counter_cache(:counter)
|
33
|
+
VisitCounter::Store.engine.redis.del set_name
|
34
|
+
@d.increase_counter
|
35
|
+
@d1.increase_counter
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should update multiple objects" do
|
39
|
+
DummyObject.stub(:where).and_return([@d, @d1])
|
40
|
+
DummyObject.should_receive(:update_all).once.with("counter = 1", "id = 1").ordered
|
41
|
+
DummyObject.should_receive(:update_all).once.with("counter = 1", "id = 2").ordered
|
42
|
+
DummyObject.update_counters(:counter, (Time.now - 4))
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should only find counter hits from the given time-period" do
|
46
|
+
VisitCounter::Store.engine.redis.zincrby(set_name, -100000, d1_key)
|
47
|
+
VisitCounter::Store.engine.get_all_by_range(set_name, (Time.now - 20).to_i, Time.now.to_i).should == [d_key]
|
48
|
+
end
|
49
|
+
|
50
|
+
it "should not try to update counters without objects" do
|
51
|
+
VisitCounter::Store.engine.should_receive(:get_all_by_range).and_return([d_key, d1_key])
|
52
|
+
DummyObject.stub(:where).and_return([@d])
|
53
|
+
VisitCounter::Helper.stub(:merge_array_values).and_return([0])
|
54
|
+
DummyObject.should_receive(:update_all).once
|
55
|
+
DummyObject.update_counters(:counter, (Time.now - 4))
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
@@ -3,10 +3,10 @@ require File.expand_path('../lib/visit_counter_updater/version', __FILE__)
|
|
3
3
|
|
4
4
|
Gem::Specification.new do |gem|
|
5
5
|
gem.authors = ["Gilad Zohari"]
|
6
|
-
gem.email = ["gilad@
|
6
|
+
gem.email = ["gilad@gmail.com"]
|
7
7
|
gem.description = %q{Extending VisitCounter to stage all data to DB}
|
8
|
-
gem.summary = %q{
|
9
|
-
gem.homepage = ""
|
8
|
+
gem.summary = %q{Tom Caspy is a lazi individual.}
|
9
|
+
gem.homepage = "https://github.com/gzohari/visit-counter-updater"
|
10
10
|
|
11
11
|
gem.files = `git ls-files`.split($\)
|
12
12
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: visit_counter_updater
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -29,7 +29,7 @@ dependencies:
|
|
29
29
|
version: '0'
|
30
30
|
description: Extending VisitCounter to stage all data to DB
|
31
31
|
email:
|
32
|
-
- gilad@
|
32
|
+
- gilad@gmail.com
|
33
33
|
executables: []
|
34
34
|
extensions: []
|
35
35
|
extra_rdoc_files: []
|
@@ -40,9 +40,13 @@ files:
|
|
40
40
|
- README.md
|
41
41
|
- Rakefile
|
42
42
|
- lib/visit_counter_updater.rb
|
43
|
+
- lib/visit_counter_updater/key.rb
|
44
|
+
- lib/visit_counter_updater/store/redis_store.rb
|
43
45
|
- lib/visit_counter_updater/version.rb
|
46
|
+
- lib/visit_counter_updater/visit_counter_updater.rb
|
47
|
+
- spec/visit_counter_updater_spec.rb
|
44
48
|
- visit_counter_updater.gemspec
|
45
|
-
homepage:
|
49
|
+
homepage: https://github.com/gzohari/visit-counter-updater
|
46
50
|
licenses: []
|
47
51
|
post_install_message:
|
48
52
|
rdoc_options: []
|
@@ -65,5 +69,6 @@ rubyforge_project:
|
|
65
69
|
rubygems_version: 1.8.24
|
66
70
|
signing_key:
|
67
71
|
specification_version: 3
|
68
|
-
summary:
|
69
|
-
test_files:
|
72
|
+
summary: Tom Caspy is a lazi individual.
|
73
|
+
test_files:
|
74
|
+
- spec/visit_counter_updater_spec.rb
|