minuteman 1.0.3 → 2.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gems +3 -0
  3. data/.travis.yml +4 -3
  4. data/ORIGIN.md +21 -0
  5. data/README.md +115 -122
  6. data/Rakefile +3 -3
  7. data/SPEC.md +21 -0
  8. data/lib/minuteman.rb +88 -122
  9. data/lib/minuteman/analyzable.rb +96 -0
  10. data/lib/minuteman/analyzer.rb +21 -0
  11. data/lib/minuteman/configuration.rb +25 -0
  12. data/lib/minuteman/counter.rb +21 -0
  13. data/lib/minuteman/event.rb +12 -0
  14. data/lib/minuteman/lua/operations.lua +37 -0
  15. data/lib/minuteman/model.rb +36 -0
  16. data/lib/minuteman/result.rb +12 -0
  17. data/lib/minuteman/trigger.rb +4 -0
  18. data/lib/minuteman/user.rb +38 -0
  19. data/minuteman.gemspec +4 -5
  20. data/test/helper.rb +2 -0
  21. data/test/minuteman_bench.rb +72 -0
  22. data/test/minuteman_test.rb +204 -0
  23. metadata +44 -73
  24. data/Gemfile +0 -4
  25. data/Gemfile.lock +0 -30
  26. data/lib/minuteman/bit_operations.rb +0 -97
  27. data/lib/minuteman/bit_operations/data.rb +0 -33
  28. data/lib/minuteman/bit_operations/operation.rb +0 -115
  29. data/lib/minuteman/bit_operations/plain.rb +0 -34
  30. data/lib/minuteman/bit_operations/result.rb +0 -15
  31. data/lib/minuteman/bit_operations/with_data.rb +0 -56
  32. data/lib/minuteman/keys_methods.rb +0 -23
  33. data/lib/minuteman/time_events.rb +0 -18
  34. data/lib/minuteman/time_span.rb +0 -36
  35. data/lib/minuteman/time_spans.rb +0 -7
  36. data/lib/minuteman/time_spans/day.rb +0 -17
  37. data/lib/minuteman/time_spans/hour.rb +0 -19
  38. data/lib/minuteman/time_spans/minute.rb +0 -19
  39. data/lib/minuteman/time_spans/month.rb +0 -17
  40. data/lib/minuteman/time_spans/week.rb +0 -18
  41. data/lib/minuteman/time_spans/year.rb +0 -17
  42. data/test/bench/minuteman_bench.rb +0 -37
  43. data/test/test_helper.rb +0 -9
  44. data/test/unit/minuteman_test.rb +0 -225
@@ -0,0 +1,96 @@
1
+ require 'msgpack'
2
+
3
+ module Minuteman
4
+ module Analyzable
5
+ module ErrorPatterns
6
+ DUPLICATE = /(UniqueIndexViolation: (\w+))/.freeze
7
+ NOSCRIPT = /^NOSCRIPT/.freeze
8
+ end
9
+
10
+ def &(event)
11
+ operation("AND", [self, event])
12
+ end
13
+
14
+ def |(event)
15
+ operation("OR", [self, event])
16
+ end
17
+ alias_method :+, :|
18
+
19
+ def ^(event)
20
+ operation("XOR", [self, event])
21
+ end
22
+
23
+ def -@()
24
+ operation("NOT", [self])
25
+ end
26
+ alias :~@ :-@
27
+
28
+ def -(event)
29
+ operation("MINUS", [self, event])
30
+ end
31
+
32
+ def count
33
+ Minuteman.config.redis.call("BITCOUNT", key)
34
+ end
35
+
36
+ def include?(user)
37
+ Minuteman.config.redis.call("GETBIT", key, user.id) == 1
38
+ end
39
+
40
+ private
41
+
42
+ def key_exists?(key)
43
+ Minuteman.config.redis.call("EXISTS", key) == 1
44
+ end
45
+
46
+ def operation(action, events = [])
47
+ base_key = Minuteman.config.operations_prefix
48
+
49
+ destination_key = if action == "NOT"
50
+ "#{base_key}#{events[0].id}:#{action}"
51
+ else
52
+ src, dst = events[0].id, events[1].id
53
+ "#{base_key}#{src}:#{action}:#{dst}"
54
+ end
55
+
56
+ if key_exists?(destination_key)
57
+ return Minuteman::Result.new(destination_key)
58
+ end
59
+
60
+ script(Minuteman::LUA_OPERATIONS, 0, action.upcase.to_msgpack,
61
+ events.map(&:key).to_msgpack, destination_key.to_msgpack)
62
+
63
+ Minuteman::Result.new(destination_key)
64
+ end
65
+
66
+ # Stolen from Ohm
67
+ def script(file, *args)
68
+ begin
69
+ cache = Minuteman::LUA_CACHE[Minuteman.config.redis.url]
70
+
71
+ if cache.key?(file)
72
+ sha = cache[file]
73
+ else
74
+ src = File.read(file)
75
+ sha = Minuteman.config.redis.call("SCRIPT", "LOAD", src)
76
+
77
+ cache[file] = sha
78
+ end
79
+
80
+ Minuteman.config.redis.call!("EVALSHA", sha, *args)
81
+
82
+ rescue RuntimeError
83
+ case $!.message
84
+ when ErrorPatterns::NOSCRIPT
85
+ Minuteman::LUA_CACHE[Minuteman.config.redis.url].clear
86
+ retry
87
+ when ErrorPatterns::DUPLICATE
88
+ raise UniqueIndexViolation, $1
89
+ else
90
+ raise $!
91
+ end
92
+ end
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ require 'minuteman/event'
2
+
3
+ module Minuteman
4
+ class Analyzer
5
+ def initialize(action, klass = Minuteman::Event, user = nil)
6
+ Minuteman.patterns.keys.each do |method|
7
+ define_singleton_method(method) do |time = Time.now.utc|
8
+ if !Minuteman.patterns.include?(method)
9
+ raise MissingPattern.new(method)
10
+ end
11
+
12
+ key = Minuteman.patterns[method].call(time)
13
+ search = { type: action, time: key }
14
+ search[:user_id] = user.id if !user.nil?
15
+
16
+ klass.find_or_create(search)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module Minuteman
2
+ class Configuration
3
+ attr_accessor :redis, :patterns, :prefix, :parallel, :operations_prefix
4
+
5
+ def initialize
6
+ @redis = Ohm.redis
7
+ @prefix = "Minuteman".freeze
8
+ @operations_prefix = "#{@prefix}::Operations:"
9
+ @parallel = false
10
+
11
+ @patterns = {
12
+ year: -> (time) { time.strftime("%Y") },
13
+ month: -> (time) { time.strftime("%Y-%m") },
14
+ day: -> (time) { time.strftime("%Y-%m-%d") },
15
+ hour: -> (time) { time.strftime("%Y-%m-%d %H") },
16
+ minute: -> (time) { time.strftime("%Y-%m-%d %H:%m") },
17
+ }
18
+ end
19
+
20
+ def redis=(redis)
21
+ @redis = redis
22
+ Ohm.redis = redis
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ require 'minuteman/model'
2
+
3
+ module Minuteman
4
+ class Counter < Minuteman::Model
5
+ class User < Counter
6
+ attribute :user_id
7
+
8
+ def key
9
+ "#{super}:#{user_id}"
10
+ end
11
+ end
12
+
13
+ def incr
14
+ Minuteman.config.redis.call("INCR", key)
15
+ end
16
+
17
+ def count
18
+ Minuteman.config.redis.call("GET", key).to_i
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ require 'minuteman/model'
2
+ require 'minuteman/analyzable'
3
+
4
+ module Minuteman
5
+ class Event < Minuteman::Model
6
+ include Minuteman::Analyzable
7
+
8
+ def setbit(int)
9
+ Minuteman.config.redis.call("SETBIT", key, int, 1)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,37 @@
1
+ ---
2
+ redis.log(redis.LOG_NOTICE, 'Minuteman')
3
+
4
+ local action = cmsgpack.unpack(ARGV[1])
5
+ local keys = cmsgpack.unpack(ARGV[2])
6
+ local dest = cmsgpack.unpack(ARGV[3])
7
+
8
+ local function operate(action, keys)
9
+ if type(keys) == "string" then keys = { keys } end
10
+
11
+ redis.call("BITOP", action, dest, unpack(keys) )
12
+
13
+ return dest
14
+ end
15
+
16
+ local function AND(keys) return operate("AND", keys) end
17
+ local function OR(keys) return operate("OR", keys) end
18
+ local function XOR(keys) return operate("XOR", keys) end
19
+ local function NOT(keys) return operate("NOT", keys) end
20
+
21
+ local function MINUS(keys)
22
+ local items = keys
23
+ local src = table.remove(items, 1)
24
+ local and_op = AND(keys)
25
+
26
+ return XOR({ src, and_op })
27
+ end
28
+
29
+ local function operation(action, keys)
30
+ if action == "MINUS" then
31
+ return MINUS(keys)
32
+ else
33
+ return operate(action, keys)
34
+ end
35
+ end
36
+
37
+ return operation(action, keys)
@@ -0,0 +1,36 @@
1
+ require 'ohm'
2
+
3
+ module Minuteman
4
+ class Model < ::Ohm::Model
5
+ attribute :type
6
+ attribute :time
7
+
8
+ def self.find(*args)
9
+ looked_up = "#{self.name}::#{args.first[:type]}:#{args.first[:time]}:id"
10
+ potential_id = Minuteman.config.redis.call("GET", looked_up)
11
+
12
+ return nil if !potential_id
13
+
14
+ event = self[potential_id]
15
+ event.type = args.first[:type]
16
+ event.time = args.first[:time]
17
+
18
+ event
19
+ end
20
+
21
+ def self.find_or_create(*args)
22
+ find(*args) || create(*args)
23
+ end
24
+
25
+ def self.create(*args)
26
+ event = super(*args)
27
+ Minuteman.config.redis.call("SET", "#{event.key}:id", event.id)
28
+ event
29
+ end
30
+
31
+ def key
32
+ "#{self.class.name}::#{type}:#{time}"
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ require 'minuteman/configuration'
2
+ require 'minuteman/analyzable'
3
+
4
+ module Minuteman
5
+ Result = Struct.new(:key) do
6
+ include Minuteman::Analyzable
7
+
8
+ def id
9
+ @_id ||= "(#{key.gsub(Minuteman.config.operations_prefix, "")})"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module Minuteman
2
+ module Trigger
3
+ end
4
+ end
@@ -0,0 +1,38 @@
1
+ require 'ohm'
2
+ require 'securerandom'
3
+
4
+ module Minuteman
5
+ class User < ::Ohm::Model
6
+ attribute :uid
7
+ attribute :identifier
8
+
9
+ unique :uid
10
+ unique :identifier
11
+
12
+ def save
13
+ self.uid ||= SecureRandom.uuid
14
+ super
15
+ end
16
+
17
+ def track(action, time = Time.now.utc)
18
+ Minuteman.track(action, self, time)
19
+ end
20
+
21
+ def add(action, time = Time.now.utc)
22
+ Minuteman.add(action, time, self)
23
+ end
24
+
25
+ def count(action, time = Time.now.utc)
26
+ Minuteman::Analyzer.new(action, Minuteman::Counter::User, self)
27
+ end
28
+
29
+ def promote(identifier)
30
+ self.identifier = identifier
31
+ save
32
+ end
33
+
34
+ def self.[](identifier_or_uuid)
35
+ with(:uid, identifier_or_uuid) || with(:identifier, identifier_or_uuid)
36
+ end
37
+ end
38
+ end
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "minuteman"
3
- s.version = "1.0.3"
3
+ s.version = "2.0.0.pre"
4
4
  s.summary = "Bit Analytics"
5
5
  s.description = "Fast and furious tracking system using Redis bitwise operations"
6
6
  s.authors = ["elcuervo"]
@@ -10,9 +10,8 @@ Gem::Specification.new do |s|
10
10
  s.files = `git ls-files`.split("\n")
11
11
  s.test_files = `git ls-files test`.split("\n")
12
12
 
13
- s.add_dependency("redis", "~> 3.0.3")
13
+ s.add_dependency("redic", "~> 1.5.0")
14
+ s.add_dependency("ohm", "~> 2.3.0")
14
15
 
15
- s.add_development_dependency("minitest", "~> 4.3.0")
16
- s.add_development_dependency("minitest-given", "~> 3.0.0")
17
- s.add_development_dependency("redis-namespace", "~> 1.2.1")
16
+ s.add_development_dependency("cutest", "~> 1.2.2")
18
17
  end
@@ -0,0 +1,2 @@
1
+ require 'cutest'
2
+ require 'minuteman'
@@ -0,0 +1,72 @@
1
+ require 'minuteman'
2
+ require 'benchmark/ips'
3
+
4
+ Minuteman.configure do |config|
5
+ config.redis = Redic.new("redis://127.0.0.1:6379/1")
6
+ config.parallel = true
7
+ end
8
+
9
+ Minuteman.config.redis.call("FLUSHDB")
10
+
11
+ users = Array.new(5000) { Minuteman::User.create }
12
+ users_one = users.sample(5000)
13
+ users_two = users.sample(2000)
14
+ users_three = users.sample(1000)
15
+
16
+ Minuteman.track("first:page", users_one)
17
+ Minuteman.track("second:page", users_two)
18
+ Minuteman.track("third:page", users_three)
19
+
20
+ Benchmark.ips do |x|
21
+ x.report("tracking users") { Minuteman.track("first:page", users_three) }
22
+
23
+ x.report("tracking annoymous users") {
24
+ Minuteman.track("first:page")
25
+ }
26
+
27
+ x.report("operation: AND") {
28
+ Minuteman("first:page").day & Minuteman("second:page").day
29
+ }
30
+
31
+ x.report("operation: OR") {
32
+ Minuteman("first:page").day | Minuteman("second:page").day
33
+ }
34
+
35
+ x.report("operation: XOR") {
36
+ Minuteman("first:page").day ^ Minuteman("second:page").day
37
+ }
38
+
39
+ x.report("operation: NOT") {
40
+ -Minuteman("second:page").day
41
+ }
42
+
43
+ x.report("operation: MINUS") {
44
+ Minuteman("first:page").day + Minuteman("second:page").day
45
+ }
46
+
47
+ x.report("complex operations") {
48
+ (
49
+ Minuteman("first:page").day + Minuteman("second:page").day
50
+ ) - Minuteman("third:page").day
51
+ }
52
+
53
+ x.report("adding to the counter") {
54
+ Minuteman.add("first:counter")
55
+ }
56
+
57
+ x.report("checking the counter") {
58
+ Counterman("first:counter").month.count
59
+ }
60
+
61
+ x.report("tracking through a user") {
62
+ users.sample.track("some:event")
63
+ }
64
+
65
+ x.report("counting through a user") {
66
+ users.sample.add("some:event")
67
+ }
68
+
69
+ x.report("checking the counter through a user") {
70
+ users.sample.count("some:event").day.count
71
+ }
72
+ end
@@ -0,0 +1,204 @@
1
+ require 'helper'
2
+
3
+ @patterns = Minuteman.patterns
4
+
5
+ prepare do
6
+ Minuteman.configure do |config|
7
+ config.redis = Redic.new("redis://127.0.0.1:6379/1")
8
+ end
9
+ end
10
+
11
+ setup do
12
+ Minuteman.config.redis.call("FLUSHDB")
13
+
14
+ Minuteman.configure do |config|
15
+ config.patterns = @patterns
16
+ end
17
+ end
18
+
19
+ test "a connection" do
20
+ assert_equal Minuteman.config.redis.class, Redic
21
+ end
22
+
23
+ test "models in minuteman namespace" do
24
+ assert_equal Minuteman::User.create.key, "Minuteman::User:1"
25
+ end
26
+
27
+ test "an anonymous user" do
28
+ user = Minuteman::User.create
29
+
30
+ assert user.is_a?(Minuteman::User)
31
+ assert !!user.uid
32
+ assert !user.identifier
33
+ assert user.id
34
+ end
35
+
36
+ test "access a user with and id or an uuid" do
37
+ user = Minuteman::User.create(identifier: 5)
38
+
39
+ assert Minuteman::User[user.uid].is_a?(Minuteman::User)
40
+ assert Minuteman::User[user.identifier].is_a?(Minuteman::User)
41
+ end
42
+
43
+ test "track an anonymous user" do
44
+ user = Minuteman.track("anonymous:user")
45
+ assert user.uid
46
+ end
47
+
48
+ test "track an user" do
49
+ user = Minuteman::User.create
50
+
51
+ assert Minuteman.track("login:successful", user)
52
+
53
+ analyzer = Minuteman.analyze("login:successful")
54
+ assert analyzer.day(Time.now.utc).count == 1
55
+ end
56
+
57
+ test "tracks an anonymous user and the promotes it to a real one" do
58
+ user = Minuteman.track("enter:website")
59
+ assert user.identifier == nil
60
+
61
+ user.promote(42)
62
+
63
+ assert user.identifier == 42
64
+ assert Minuteman::User[42].uid == user.uid
65
+ assert Minuteman("enter:website").day.include?(user)
66
+ end
67
+
68
+ test "create your own storage patterns and access analyzer" do
69
+ Minuteman.configure do |config|
70
+ config.patterns = {
71
+ dia: -> (time) { time.strftime("%Y-%m-%d") }
72
+ }
73
+ end
74
+
75
+ Minuteman.track("logeo:exitoso")
76
+ assert Minuteman("logeo:exitoso").dia.count == 1
77
+ end
78
+
79
+ test "use the method shortcut" do
80
+ 5.times { Minuteman.track("enter:website") }
81
+
82
+ assert Minuteman("enter:website").day.count == 5
83
+ end
84
+
85
+ scope "operations" do
86
+ setup do
87
+ Minuteman.config.redis.call("FLUSHDB")
88
+
89
+ @users = Array.new(3) { Minuteman::User.create }
90
+ @users.each do |user|
91
+ Minuteman.track("landing_page:new", @users)
92
+ end
93
+
94
+ Minuteman.track("buy:product", @users[0])
95
+ Minuteman.track("buy:product", @users[2])
96
+ end
97
+
98
+ test "AND" do
99
+ and_op = Minuteman("landing_page:new").day & Minuteman("buy:product").day
100
+ assert and_op.count == 2
101
+ end
102
+
103
+ test "OR" do
104
+ or_op = Minuteman("landing_page:new").day | Minuteman("buy:product").day
105
+ assert or_op.count == 3
106
+ end
107
+
108
+ test "XOR" do
109
+ xor_op = Minuteman("landing_page:new").day ^ Minuteman("buy:product").day
110
+ assert xor_op.count == 1
111
+ end
112
+
113
+ test "NOT" do
114
+ assert Minuteman("buy:product").day.include?(@users[2])
115
+
116
+ not_op = -Minuteman("buy:product").day
117
+ assert !not_op.include?(@users[2])
118
+ end
119
+
120
+ test "MINUS" do
121
+ assert Minuteman("landing_page:new").day.include?(@users[2])
122
+ assert Minuteman("buy:product").day.include?(@users[2])
123
+
124
+ minus_op = Minuteman("landing_page:new").day - Minuteman("buy:product").day
125
+
126
+ assert !minus_op.include?(@users[2])
127
+ assert minus_op.include?(@users[1])
128
+ end
129
+ end
130
+
131
+ scope "complex operations" do
132
+ setup do
133
+ Minuteman.config.redis.call("FLUSHDB")
134
+ @users = Array.new(6) { Minuteman::User.create }
135
+
136
+ [ @users[0], @users[1], @users[2] ].each do |u|
137
+ Minuteman.track("promo:email", u)
138
+ end
139
+
140
+ [ @users[3], @users[4], @users[5] ].each do |u|
141
+ Minuteman.track("promo:facebook", u)
142
+ end
143
+
144
+ [ @users[1], @users[4], @users[6] ].each do |u|
145
+ Minuteman.track("user:new", u)
146
+ end
147
+ end
148
+
149
+ test "verbose" do
150
+ got_promos = Minuteman("promo:email").day + Minuteman("promo:facebook").day
151
+
152
+ @users[0..5].each do |u|
153
+ assert got_promos.include?(u)
154
+ end
155
+
156
+ new_users = Minuteman("user:new").day
157
+ query = got_promos & new_users
158
+
159
+ [ @users[1], @users[4] ].each do |u|
160
+ assert query.include?(u)
161
+ end
162
+
163
+ assert query.count == 2
164
+ end
165
+
166
+ test "readable" do
167
+ query = (
168
+ Minuteman("promo:email").day + Minuteman("promo:facebook").day
169
+ ) & Minuteman("user:new").day
170
+
171
+ assert query.count == 2
172
+ end
173
+ end
174
+
175
+ test "count a given event" do
176
+ 10.times { Minuteman.add("enter:new_landing") }
177
+
178
+ assert Counterman("enter:new_landing").day.count == 10
179
+ end
180
+
181
+ test "count events on some dates" do
182
+ day = Time.new(2015, 10, 15)
183
+ next_day = Time.new(2015, 10, 16)
184
+
185
+ 5.times { Minuteman.add("drink:beer", day) }
186
+ 2.times { Minuteman.add("drink:beer", next_day) }
187
+
188
+ assert Counterman("drink:beer").month(day).count == 7
189
+ assert Counterman("drink:beer").day(day).count == 5
190
+ end
191
+
192
+ scope "do actions through a user" do
193
+ test "track an event" do
194
+ user = Minuteman::User.create
195
+ user.track("login:page")
196
+
197
+ 3.times { user.add("login:attempts") }
198
+ 2.times { Minuteman.add("login:attempts") }
199
+
200
+ assert Minuteman("login:page").day.include?(user)
201
+ assert Counterman("login:attempts").day.count == 5
202
+ assert user.count("login:attempts").day.count == 3
203
+ end
204
+ end