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.
Files changed (43) hide show
  1. data/.rspec +1 -0
  2. data/.travis.yml +3 -1
  3. data/CHANGELOG.md +7 -0
  4. data/Gemfile +4 -0
  5. data/README.md +33 -13
  6. data/lib/lifesaver.rb +34 -11
  7. data/lib/lifesaver/config.rb +16 -0
  8. data/lib/lifesaver/index_worker.rb +10 -10
  9. data/lib/lifesaver/indexing/enqueuer.rb +37 -0
  10. data/lib/lifesaver/indexing/indexer.rb +40 -0
  11. data/lib/lifesaver/indexing/model_additions.rb +55 -0
  12. data/lib/lifesaver/notification/eager_loader.rb +48 -0
  13. data/lib/lifesaver/notification/enqueuer.rb +24 -0
  14. data/lib/lifesaver/notification/indexing_graph.rb +91 -0
  15. data/lib/lifesaver/notification/model_additions.rb +104 -0
  16. data/lib/lifesaver/notification/notifiable_associations.rb +49 -0
  17. data/lib/lifesaver/notification/traversal_queue.rb +48 -0
  18. data/lib/lifesaver/railtie.rb +4 -3
  19. data/lib/lifesaver/serialized_model.rb +3 -0
  20. data/lib/lifesaver/version.rb +1 -1
  21. data/lib/lifesaver/visitor_worker.rb +8 -4
  22. data/spec/integration/lifesaver_spec.rb +78 -0
  23. data/spec/spec_helper.rb +12 -10
  24. data/spec/support/active_record.rb +3 -3
  25. data/spec/support/test_models.rb +8 -6
  26. data/spec/support/tire_helper.rb +37 -0
  27. data/spec/unit/config_spec.rb +17 -0
  28. data/spec/unit/indexing/indexer_spec.rb +51 -0
  29. data/spec/unit/indexing/model_addtions_spec.rb +53 -0
  30. data/spec/unit/notification/eager_loader_spec.rb +64 -0
  31. data/spec/unit/notification/enqueuer_spec.rb +39 -0
  32. data/spec/unit/notification/indexing_graph_spec.rb +73 -0
  33. data/spec/unit/notification/model_additions_spec.rb +69 -0
  34. data/spec/unit/notification/notifiable_associations_spec.rb +75 -0
  35. data/spec/unit/notification/traversal_queue_spec.rb +67 -0
  36. metadata +40 -14
  37. data/lib/lifesaver/index_graph.rb +0 -53
  38. data/lib/lifesaver/marshal.rb +0 -43
  39. data/lib/lifesaver/model_additions.rb +0 -133
  40. data/spec/lifesaver/index_graph_spec.rb +0 -64
  41. data/spec/lifesaver/marshal_spec.rb +0 -80
  42. data/spec/lifesaver/model_additions_spec.rb +0 -91
  43. 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
@@ -1,9 +1,10 @@
1
1
  module Lifesaver
2
2
  class Railtie < Rails::Railtie
3
- initializer 'lifesaver.model_additions' do
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
@@ -0,0 +1,3 @@
1
+ module Lifesaver
2
+ class SerializedModel < Struct.new(:class_name, :id); end
3
+ end
@@ -1,3 +1,3 @@
1
1
  module Lifesaver
2
- VERSION = "0.0.1"
2
+ VERSION = '0.1.0'
3
3
  end
@@ -1,9 +1,13 @@
1
1
  class Lifesaver::VisitorWorker
2
2
  include Resque::Plugins::UniqueJob
3
- @queue = :lifesaver_notification
3
+
4
+ def self.queue; Lifesaver.config.notification_queue end
5
+
4
6
  def self.perform(models)
5
- Lifesaver::IndexGraph.generate(models).each do |m|
6
- Resque.enqueue(Lifesaver::IndexWorker, m.class.name.underscore.to_sym, m.id, :update) if m.has_index?
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
- ActiveSupport.on_load :active_record do
9
- include Lifesaver::ModelAdditions
10
- end
9
+ require 'support/tire_helper'
11
10
 
12
11
  Resque.inline = true
13
- Tire::Model::Search.index_prefix "lifesaver_test"
12
+ Tire::Model::Search.index_prefix 'lifesaver_test'
14
13
 
14
+ Model = Struct.new(:id)
15
15
 
16
16
  RSpec.configure do |config|
17
- # config.expect_with :rspec do |c|
18
- # c.syntax = :expect
19
- # end
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
- raise ActiveRecord::Rollback
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: "sqlite3", database: ":memory:"
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
- [self.errors[attribute]].flatten.compact
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
@@ -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