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.
@@ -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