visit_counter_updater 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module VisitCounterUpdater
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2"
3
3
  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@ftbpro.com"]
6
+ gem.email = ["gilad@gmail.com"]
7
7
  gem.description = %q{Extending VisitCounter to stage all data to DB}
8
- gem.summary = %q{This fucking sucks.}
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.1
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@ftbpro.com
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: This fucking sucks.
69
- test_files: []
72
+ summary: Tom Caspy is a lazi individual.
73
+ test_files:
74
+ - spec/visit_counter_updater_spec.rb