lifesaver 0.0.1 → 0.1.0
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.
- 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
|