lifesaver 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.rspec +1 -0
- data/.travis.yml +3 -1
- data/CHANGELOG.md +7 -0
- data/Gemfile +4 -0
- data/README.md +33 -13
- data/lib/lifesaver.rb +34 -11
- data/lib/lifesaver/config.rb +16 -0
- data/lib/lifesaver/index_worker.rb +10 -10
- data/lib/lifesaver/indexing/enqueuer.rb +37 -0
- data/lib/lifesaver/indexing/indexer.rb +40 -0
- data/lib/lifesaver/indexing/model_additions.rb +55 -0
- data/lib/lifesaver/notification/eager_loader.rb +48 -0
- data/lib/lifesaver/notification/enqueuer.rb +24 -0
- data/lib/lifesaver/notification/indexing_graph.rb +91 -0
- data/lib/lifesaver/notification/model_additions.rb +104 -0
- data/lib/lifesaver/notification/notifiable_associations.rb +49 -0
- data/lib/lifesaver/notification/traversal_queue.rb +48 -0
- data/lib/lifesaver/railtie.rb +4 -3
- data/lib/lifesaver/serialized_model.rb +3 -0
- data/lib/lifesaver/version.rb +1 -1
- data/lib/lifesaver/visitor_worker.rb +8 -4
- data/spec/integration/lifesaver_spec.rb +78 -0
- data/spec/spec_helper.rb +12 -10
- data/spec/support/active_record.rb +3 -3
- data/spec/support/test_models.rb +8 -6
- data/spec/support/tire_helper.rb +37 -0
- data/spec/unit/config_spec.rb +17 -0
- data/spec/unit/indexing/indexer_spec.rb +51 -0
- data/spec/unit/indexing/model_addtions_spec.rb +53 -0
- data/spec/unit/notification/eager_loader_spec.rb +64 -0
- data/spec/unit/notification/enqueuer_spec.rb +39 -0
- data/spec/unit/notification/indexing_graph_spec.rb +73 -0
- data/spec/unit/notification/model_additions_spec.rb +69 -0
- data/spec/unit/notification/notifiable_associations_spec.rb +75 -0
- data/spec/unit/notification/traversal_queue_spec.rb +67 -0
- metadata +40 -14
- data/lib/lifesaver/index_graph.rb +0 -53
- data/lib/lifesaver/marshal.rb +0 -43
- data/lib/lifesaver/model_additions.rb +0 -133
- data/spec/lifesaver/index_graph_spec.rb +0 -64
- data/spec/lifesaver/marshal_spec.rb +0 -80
- data/spec/lifesaver/model_additions_spec.rb +0 -91
- data/spec/lifesaver_spec.rb +0 -111
@@ -0,0 +1,104 @@
|
|
1
|
+
module Lifesaver
|
2
|
+
module Notification
|
3
|
+
module ModelAdditions
|
4
|
+
module ClassMethods
|
5
|
+
def notifies_for_indexing(*args)
|
6
|
+
self.notifiable_associations = NotifiableAssociations.new
|
7
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
8
|
+
notifiable_associations.populate(args, options)
|
9
|
+
notification_callbacks
|
10
|
+
end
|
11
|
+
|
12
|
+
def load_with_notifiable_associations(ids)
|
13
|
+
includes(notifiable_associations.on_notify).where(id: ids)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def notification_callbacks
|
19
|
+
after_save do
|
20
|
+
send :update_associations, :update
|
21
|
+
end
|
22
|
+
before_destroy do
|
23
|
+
send :update_associations, :destroy
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.included(base)
|
29
|
+
base.class_attribute :notifiable_associations
|
30
|
+
base.notifiable_associations = NotifiableAssociations.new
|
31
|
+
base.extend(ClassMethods)
|
32
|
+
end
|
33
|
+
|
34
|
+
def associations_to_notify
|
35
|
+
models = []
|
36
|
+
self.class.notifiable_associations.on_notify.each do |association|
|
37
|
+
models |= models_for_association(association)
|
38
|
+
end
|
39
|
+
models
|
40
|
+
end
|
41
|
+
|
42
|
+
def needs_to_notify?
|
43
|
+
self.class.notifiable_associations.any_to_notify?
|
44
|
+
end
|
45
|
+
|
46
|
+
def models_for_association(assoc)
|
47
|
+
models = []
|
48
|
+
association = send(assoc)
|
49
|
+
unless association.nil?
|
50
|
+
if association.respond_to?(:each)
|
51
|
+
association.each do |m|
|
52
|
+
models << m
|
53
|
+
end
|
54
|
+
else
|
55
|
+
models << association
|
56
|
+
end
|
57
|
+
end
|
58
|
+
models
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def update_associations(operation) models = []
|
64
|
+
to_skip = operation == :destroy ? dependent_associations : []
|
65
|
+
to_load = associations_to_load(:on_change, to_skip)
|
66
|
+
|
67
|
+
models = []
|
68
|
+
to_load.each { |key| models |= models_for_association(key) }
|
69
|
+
serialized_models = serialize_models(models)
|
70
|
+
enqueue_worker(serialized_models)
|
71
|
+
end
|
72
|
+
|
73
|
+
def associations_to_load(key, skip_associations)
|
74
|
+
associations = self.class.notifiable_associations.public_send(key)
|
75
|
+
skip_associations_map = {}
|
76
|
+
skip_associations.each { |assoc| skip_associations_map[assoc] = true }
|
77
|
+
associations.reject { |assoc| skip_associations_map[assoc] }
|
78
|
+
end
|
79
|
+
|
80
|
+
def dependent_associations
|
81
|
+
dependent_associations = []
|
82
|
+
self.class.reflect_on_all_associations.each do |association|
|
83
|
+
if association.options[:dependent].present?
|
84
|
+
dependent_associations << association.name.to_sym
|
85
|
+
end
|
86
|
+
end
|
87
|
+
dependent_associations
|
88
|
+
end
|
89
|
+
|
90
|
+
def serialize_models(models)
|
91
|
+
serialized_models = []
|
92
|
+
models.each do |m|
|
93
|
+
serialized_models << Lifesaver::SerializedModel.new(m.class.name, m.id)
|
94
|
+
end
|
95
|
+
serialized_models
|
96
|
+
end
|
97
|
+
|
98
|
+
def enqueue_worker(serialized_models)
|
99
|
+
Lifesaver::Notification::Enqueuer.new(serialized_models).enqueue
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Lifesaver
|
2
|
+
module Notification
|
3
|
+
class NotifiableAssociations
|
4
|
+
class AssociationKeys < Struct.new(:on_change, :on_notify); end
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@association_keys = AssociationKeys.new([], [])
|
8
|
+
end
|
9
|
+
|
10
|
+
def populate(associations, options = nil)
|
11
|
+
options ||= {}
|
12
|
+
add_associations(:on_change, associations)
|
13
|
+
add_associations(:on_notify, associations)
|
14
|
+
|
15
|
+
if options[:only_on_change]
|
16
|
+
add_associations(:on_change, options[:only_on_change])
|
17
|
+
end
|
18
|
+
|
19
|
+
if options[:only_on_notify]
|
20
|
+
add_associations(:on_notify, options[:only_on_notify])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def on_notify
|
25
|
+
association_keys.on_notify
|
26
|
+
end
|
27
|
+
|
28
|
+
def on_change
|
29
|
+
association_keys.on_change
|
30
|
+
end
|
31
|
+
|
32
|
+
def any_to_notify?
|
33
|
+
!on_notify.empty?
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
attr_accessor :association_keys
|
39
|
+
|
40
|
+
def add_associations(key, associations)
|
41
|
+
if associations.is_a?(Array)
|
42
|
+
association_keys[key] |= associations
|
43
|
+
else
|
44
|
+
association_keys[key] << associations
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Lifesaver
|
2
|
+
module Notification
|
3
|
+
class TraversalQueue
|
4
|
+
def initialize
|
5
|
+
@visited_models = {}
|
6
|
+
@queue = []
|
7
|
+
end
|
8
|
+
|
9
|
+
def size
|
10
|
+
queue.size
|
11
|
+
end
|
12
|
+
|
13
|
+
def push(model)
|
14
|
+
return if model_visited?(model)
|
15
|
+
visit_model(model)
|
16
|
+
queue << model
|
17
|
+
end
|
18
|
+
|
19
|
+
def <<(model)
|
20
|
+
push(model)
|
21
|
+
end
|
22
|
+
|
23
|
+
def pop
|
24
|
+
queue.shift
|
25
|
+
end
|
26
|
+
|
27
|
+
def empty?
|
28
|
+
queue.empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
attr_accessor :queue, :visited_models
|
34
|
+
|
35
|
+
def visit_model(model)
|
36
|
+
visited_models[model_key(model)] = true
|
37
|
+
end
|
38
|
+
|
39
|
+
def model_visited?(model)
|
40
|
+
visited_models[model_key(model)] || false
|
41
|
+
end
|
42
|
+
|
43
|
+
def model_key(model)
|
44
|
+
"#{model.class.name}_#{model.id}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/lifesaver/railtie.rb
CHANGED
@@ -1,9 +1,10 @@
|
|
1
1
|
module Lifesaver
|
2
2
|
class Railtie < Rails::Railtie
|
3
|
-
initializer 'lifesaver.
|
3
|
+
initializer 'lifesaver.model' do
|
4
4
|
ActiveSupport.on_load :active_record do
|
5
|
-
include ModelAdditions
|
5
|
+
include Indexing::ModelAdditions
|
6
|
+
include Notification::ModelAdditions
|
6
7
|
end
|
7
8
|
end
|
8
9
|
end
|
9
|
-
end
|
10
|
+
end
|
data/lib/lifesaver/version.rb
CHANGED
@@ -1,9 +1,13 @@
|
|
1
1
|
class Lifesaver::VisitorWorker
|
2
2
|
include Resque::Plugins::UniqueJob
|
3
|
-
|
3
|
+
|
4
|
+
def self.queue; Lifesaver.config.notification_queue end
|
5
|
+
|
4
6
|
def self.perform(models)
|
5
|
-
Lifesaver::
|
6
|
-
|
7
|
+
indexing_graph = Lifesaver::Notification::IndexingGraph.new
|
8
|
+
indexing_graph.initialize_models(models)
|
9
|
+
indexing_graph.generate.each do |m|
|
10
|
+
Lifesaver::Indexing::Enqueuer.new(model: m, operation: :update).enqueue
|
7
11
|
end
|
8
12
|
end
|
9
|
-
end
|
13
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Lifesaver do
|
4
|
+
before do
|
5
|
+
Lifesaver.suppress_indexing
|
6
|
+
end
|
7
|
+
let(:affiliate) { Affiliate.create(name: 'Prosper Forebearer') }
|
8
|
+
let(:author) do
|
9
|
+
Author.create(name: 'Theo Epstein', affiliate_id: affiliate.id)
|
10
|
+
end
|
11
|
+
let(:post) do
|
12
|
+
Post.create(
|
13
|
+
title: 'Lifesavers are my favorite candy',
|
14
|
+
content: 'Lorem ipsum',
|
15
|
+
tags: %w(candy stuff opinions)
|
16
|
+
)
|
17
|
+
end
|
18
|
+
let(:comment) do
|
19
|
+
Comment.create(
|
20
|
+
post: post,
|
21
|
+
text: 'We love this!'
|
22
|
+
)
|
23
|
+
end
|
24
|
+
before do
|
25
|
+
Authorship.create(post: post, author: author)
|
26
|
+
author.reload
|
27
|
+
post.reload
|
28
|
+
|
29
|
+
setup_indexes([author, post])
|
30
|
+
|
31
|
+
Lifesaver.unsuppress_indexing
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should traverse the provided graph' do
|
35
|
+
input = [{ 'class_name' => 'Author', 'id' => 1 }]
|
36
|
+
output = [author, post]
|
37
|
+
indexing_graph = Lifesaver::Notification::IndexingGraph.new
|
38
|
+
indexing_graph.initialize_models(input)
|
39
|
+
|
40
|
+
models = indexing_graph.generate
|
41
|
+
|
42
|
+
expect(models).to eql(output)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'should reindex on destroy' do
|
46
|
+
author.destroy
|
47
|
+
sleep(1.seconds)
|
48
|
+
|
49
|
+
expect(Author.search(query: 'Theo Epstein').count).to eql(0)
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'should reindex on update' do
|
53
|
+
author.name = 'Harry Carry'
|
54
|
+
author.save!
|
55
|
+
sleep(1.seconds)
|
56
|
+
|
57
|
+
expect(Author.search(query: 'Harry Carry').count).to eql(1)
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'should update distant related indexes' do
|
61
|
+
post.tags << 'werd'
|
62
|
+
post.save!
|
63
|
+
sleep(1.seconds)
|
64
|
+
|
65
|
+
expect(Author.search(query: 'werd').count).to eql(1)
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should update related indexes if saved model doesn't have index" do
|
69
|
+
comment.text = 'We hate this!'
|
70
|
+
comment.save!
|
71
|
+
sleep(1.seconds)
|
72
|
+
|
73
|
+
result = Post.search(query: 'Lifesavers').to_a.first
|
74
|
+
comment_text = result.comments.first.text
|
75
|
+
|
76
|
+
expect(comment_text).to eql('We hate this!')
|
77
|
+
end
|
78
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,27 +1,29 @@
|
|
1
|
+
require 'pry'
|
1
2
|
require 'coveralls'
|
2
|
-
Coveralls.wear!
|
3
|
+
Coveralls.wear! if ENV['RUN_COVERALLS']
|
3
4
|
|
4
5
|
require 'lifesaver'
|
5
6
|
|
6
7
|
require 'support/active_record'
|
7
8
|
require 'support/test_models'
|
8
|
-
|
9
|
-
include Lifesaver::ModelAdditions
|
10
|
-
end
|
9
|
+
require 'support/tire_helper'
|
11
10
|
|
12
11
|
Resque.inline = true
|
13
|
-
Tire::Model::Search.index_prefix
|
12
|
+
Tire::Model::Search.index_prefix 'lifesaver_test'
|
14
13
|
|
14
|
+
Model = Struct.new(:id)
|
15
15
|
|
16
16
|
RSpec.configure do |config|
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
config.include TireHelper
|
18
|
+
|
19
|
+
config.expect_with :rspec do |c|
|
20
|
+
c.syntax = :expect
|
21
|
+
end
|
20
22
|
|
21
23
|
config.around do |example|
|
22
24
|
ActiveRecord::Base.transaction do
|
23
25
|
example.run
|
24
|
-
|
26
|
+
fail ActiveRecord::Rollback
|
25
27
|
end
|
26
28
|
end
|
27
|
-
end
|
29
|
+
end
|
@@ -1,5 +1,5 @@
|
|
1
1
|
require 'active_record'
|
2
|
-
ActiveRecord::Base.establish_connection adapter:
|
2
|
+
ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:'
|
3
3
|
|
4
4
|
module ActiveModel::Validations
|
5
5
|
# Extension to enhance `should have` on AR Model instances. Calls
|
@@ -16,7 +16,7 @@ module ActiveModel::Validations
|
|
16
16
|
# model.errors_on(:attribute).should include("can't be blank")
|
17
17
|
def errors_on(attribute)
|
18
18
|
self.valid?
|
19
|
-
[
|
19
|
+
[errors[attribute]].flatten.compact
|
20
20
|
end
|
21
21
|
alias :error_on :errors_on
|
22
22
|
end
|
@@ -50,4 +50,4 @@ end
|
|
50
50
|
ActiveRecord::Migration.create_table :affiliates do |t|
|
51
51
|
t.string :name
|
52
52
|
t.timestamps
|
53
|
-
end
|
53
|
+
end
|
data/spec/support/test_models.rb
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
require 'tire'
|
2
2
|
|
3
3
|
ActiveSupport.on_load :active_record do
|
4
|
-
include Lifesaver::ModelAdditions
|
4
|
+
include Lifesaver::Indexing::ModelAdditions
|
5
|
+
include Lifesaver::Notification::ModelAdditions
|
5
6
|
end
|
6
7
|
|
7
8
|
|
@@ -11,6 +12,7 @@ class Author < ActiveRecord::Base
|
|
11
12
|
belongs_to :affiliate
|
12
13
|
enqueues_indexing
|
13
14
|
include ::Tire::Model::Search
|
15
|
+
|
14
16
|
notifies_for_indexing :authorships
|
15
17
|
def post_tags
|
16
18
|
tags = Set.new
|
@@ -22,8 +24,8 @@ class Author < ActiveRecord::Base
|
|
22
24
|
mapping do
|
23
25
|
indexes :id, type: 'integer', index: 'not_analyzed'
|
24
26
|
indexes :name, type: 'multi_field', fields: {
|
25
|
-
name: {type: 'string', analyzer: 'snowball'},
|
26
|
-
untouched: {type: 'string', index: 'not_analyzed'}
|
27
|
+
name: { type: 'string', analyzer: 'snowball' },
|
28
|
+
untouched: { type: 'string', index: 'not_analyzed' }
|
27
29
|
}
|
28
30
|
indexes :post_tags, analyzer: 'keyword'
|
29
31
|
end
|
@@ -64,8 +66,8 @@ class Post < ActiveRecord::Base
|
|
64
66
|
mapping do
|
65
67
|
indexes :id, type: 'integer', index: 'not_analyzed'
|
66
68
|
indexes :title, type: 'multi_field', fields: {
|
67
|
-
title: {type: 'string', analyzer: 'snowball'},
|
68
|
-
untouched: {type: 'string', index: 'not_analyzed'}
|
69
|
+
title: { type: 'string', analyzer: 'snowball' },
|
70
|
+
untouched: { type: 'string', index: 'not_analyzed' }
|
69
71
|
}
|
70
72
|
end
|
71
73
|
|
@@ -87,4 +89,4 @@ end
|
|
87
89
|
class Affiliate < ActiveRecord::Base
|
88
90
|
has_many :authors
|
89
91
|
notifies_for_indexing :authors
|
90
|
-
end
|
92
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module TireHelper
|
2
|
+
def setup_indexes(models)
|
3
|
+
@class_buckets = {}
|
4
|
+
bucketize_models(models)
|
5
|
+
refresh_indexes
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
attr_accessor :class_buckets
|
11
|
+
|
12
|
+
def bucketize_models(models)
|
13
|
+
models.each do |model|
|
14
|
+
add_model_to_bucket(model)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def add_model_to_bucket(model)
|
19
|
+
key = model.class.name
|
20
|
+
class_buckets[key] ||= []
|
21
|
+
class_buckets[key] << model
|
22
|
+
end
|
23
|
+
|
24
|
+
def refresh_indexes
|
25
|
+
class_buckets.each do |class_name, models|
|
26
|
+
refresh_index(class_name, models)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def refresh_index(class_name, models)
|
31
|
+
klass = class_name.constantize
|
32
|
+
klass.tire.index.delete
|
33
|
+
klass.tire.create_elasticsearch_index
|
34
|
+
models.each { |model| model.tire.update_index }
|
35
|
+
klass.tire.index.refresh
|
36
|
+
end
|
37
|
+
end
|