minuteman 1.0.3 → 2.0.0.pre

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