Skip to content

Commit 6d268c1

Browse files
meili-bors[bot]ellnixbrunoocasali
authored
Merge #290
290: Fix race condition in queued record & document removal r=brunoocasali a=ellnix # Pull Request ## Related issue Fixes #266 ## What does this PR do? The purpose of this PR is to detach the MeiliSearch document deletion process from the ActiveRecord object so that documents corresponding to a record can be deleted even if the record no longer exists in the database. Tests were also added for new functionality and to hopefully prevent regressions. ## PR checklist Please check if your PR fulfills the following requirements: - [X] Does this PR fix an existing issue, or have you listed the changes applied in the PR description (and why they are needed)? - [X] Have you read the contributing guidelines? - [X] Have you made sure that the title is accurate and descriptive of the changes? ## Solution Considerations Please read #266 for full context: - The suggested fix in #266 would not work due to the fact that the primary key of a record in an index can be dynamic (by a user-created method) and may not be reconstructed by the record's database ID alone. In addition, a record may have multiple entries in multiple indexes. - I created a new job so as not to break `MSJob` (although I suspect it would be better if it was replaced with a job dedicated to enqueued indexing, since that's all it does now anyway) - `ActiveJob` needs to somehow serialize the parameters to a job, default serialization (through `GlobalID`) relies on a database entry to reinitialize the record when the job is run - Since the record may no longer exist by the time the job runs, I pass a list of indexes and IDs that _may_ (more on that) belong to the record - I created a new set of `ms_entries` methods whose purpose is to use the current configuration to list every index the record may have a document in, along with the record's primary key in that index - The method does not check `Utilities.indexable?` so it is possible that some of the entries it returns are not actually in MeiliSearch, this is by design since its intention is to be exhaustive and in theory those primary keys _cannot_ belong to another record so deleting them should be ok Co-authored-by: ellnix <[email protected]> Co-authored-by: Bruno Casali <[email protected]>
2 parents 53febb3 + d32db1a commit 6d268c1

9 files changed

+236
-17
lines changed

.rubocop_todo.yml

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2023-11-06 11:54:44 UTC using RuboCop version 1.27.0.
3+
# on 2024-01-10 10:49:28 UTC using RuboCop version 1.27.0.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -30,6 +30,15 @@ Layout/EmptyLinesAroundModuleBody:
3030
Exclude:
3131
- 'lib/meilisearch-rails.rb'
3232

33+
# Offense count: 3
34+
# This cop supports safe auto-correction (--auto-correct).
35+
# Configuration parameters: EnforcedStyle.
36+
# SupportedStyles: symmetrical, new_line, same_line
37+
Layout/MultilineMethodCallBraceLayout:
38+
Exclude:
39+
- 'spec/integration_spec.rb'
40+
- 'spec/ms_clean_up_job_spec.rb'
41+
3342
# Offense count: 1
3443
# This cop supports safe auto-correction (--auto-correct).
3544
# Configuration parameters: EnforcedStyle, IndentationWidth.
@@ -44,6 +53,13 @@ Lint/SuppressedException:
4453
Exclude:
4554
- 'lib/meilisearch-rails.rb'
4655

56+
# Offense count: 1
57+
# This cop supports safe auto-correction (--auto-correct).
58+
# Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments.
59+
Lint/UnusedBlockArgument:
60+
Exclude:
61+
- 'lib/meilisearch-rails.rb'
62+
4763
# Offense count: 2
4864
# This cop supports safe auto-correction (--auto-correct).
4965
# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods.
@@ -54,7 +70,7 @@ Lint/UnusedMethodArgument:
5470
# Offense count: 11
5571
# Configuration parameters: IgnoredMethods, CountRepeatedAttributes.
5672
Metrics/AbcSize:
57-
Max: 102
73+
Max: 104
5874

5975
# Offense count: 1
6076
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
@@ -75,17 +91,17 @@ Metrics/CyclomaticComplexity:
7591
# Offense count: 16
7692
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
7793
Metrics/MethodLength:
78-
Max: 99
94+
Max: 103
7995

8096
# Offense count: 1
8197
# Configuration parameters: CountComments, CountAsOne.
8298
Metrics/ModuleLength:
83-
Max: 435
99+
Max: 449
84100

85101
# Offense count: 8
86102
# Configuration parameters: IgnoredMethods.
87103
Metrics/PerceivedComplexity:
88-
Max: 33
104+
Max: 34
89105

90106
# Offense count: 1
91107
Naming/AccessorMethodName:
@@ -107,7 +123,7 @@ Naming/MethodParameterName:
107123
Exclude:
108124
- 'lib/meilisearch-rails.rb'
109125

110-
# Offense count: 15
126+
# Offense count: 20
111127
RSpec/BeforeAfterAll:
112128
Exclude:
113129
- 'spec/integration_spec.rb'
@@ -118,7 +134,7 @@ RSpec/DescribeClass:
118134
Exclude:
119135
- 'spec/integration_spec.rb'
120136

121-
# Offense count: 37
137+
# Offense count: 46
122138
# Configuration parameters: CountAsOne.
123139
RSpec/ExampleLength:
124140
Max: 19
@@ -132,7 +148,7 @@ RSpec/FilePath:
132148
- 'spec/settings_spec.rb'
133149
- 'spec/utilities_spec.rb'
134150

135-
# Offense count: 26
151+
# Offense count: 25
136152
# Configuration parameters: AssignmentOnly.
137153
RSpec/InstanceVariable:
138154
Exclude:
@@ -150,6 +166,12 @@ RSpec/MultipleDescribes:
150166
Exclude:
151167
- 'spec/integration_spec.rb'
152168

169+
# Offense count: 1
170+
# This cop supports safe auto-correction (--auto-correct).
171+
RSpec/MultipleSubjects:
172+
Exclude:
173+
- 'spec/ms_clean_up_job_spec.rb'
174+
153175
# Offense count: 1
154176
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
155177
RSpec/VerifiedDoubles:
@@ -188,14 +210,45 @@ Style/InverseMethods:
188210
Exclude:
189211
- 'lib/meilisearch-rails.rb'
190212

191-
# Offense count: 8
213+
# Offense count: 1
214+
# This cop supports safe auto-correction (--auto-correct).
215+
Style/MultilineIfModifier:
216+
Exclude:
217+
- 'lib/meilisearch-rails.rb'
218+
219+
# Offense count: 1
220+
# This cop supports safe auto-correction (--auto-correct).
221+
# Configuration parameters: AllowedMethods.
222+
# AllowedMethods: be, be_a, be_an, be_between, be_falsey, be_kind_of, be_instance_of, be_truthy, be_within, eq, eql, end_with, include, match, raise_error, respond_to, start_with
223+
Style/NestedParenthesizedCalls:
224+
Exclude:
225+
- 'spec/ms_clean_up_job_spec.rb'
226+
227+
# Offense count: 9
192228
# Configuration parameters: AllowedMethods.
193229
# AllowedMethods: respond_to_missing?
194230
Style/OptionalBooleanParameter:
195231
Exclude:
196232
- 'lib/meilisearch-rails.rb'
197233

198-
# Offense count: 20
234+
# Offense count: 13
235+
# This cop supports safe auto-correction (--auto-correct).
236+
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
237+
# SupportedStyles: single_quotes, double_quotes
238+
Style/StringLiterals:
239+
Exclude:
240+
- 'spec/integration_spec.rb'
241+
- 'spec/ms_clean_up_job_spec.rb'
242+
243+
# Offense count: 2
244+
# This cop supports safe auto-correction (--auto-correct).
245+
# Configuration parameters: EnforcedStyleForMultiline.
246+
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
247+
Style/TrailingCommaInArguments:
248+
Exclude:
249+
- 'spec/integration_spec.rb'
250+
251+
# Offense count: 19
199252
# This cop supports safe auto-correction (--auto-correct).
200253
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
201254
# URISchemes: http, https

lib/meilisearch-rails.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ def additional_indexes
249249
# lazy load the ActiveJob class to ensure the
250250
# queue is initialized before using it
251251
autoload :MSJob, 'meilisearch/rails/ms_job'
252+
autoload :MSCleanUpJob, 'meilisearch/rails/ms_clean_up_job'
252253
end
253254

254255
# this class wraps an MeiliSearch::Index document ensuring all raised exceptions
@@ -382,7 +383,11 @@ def meilisearch(options = {}, &block)
382383

383384
proc = if options[:enqueue] == true
384385
proc do |record, remove|
385-
MSJob.perform_later(record, remove ? 'ms_remove_from_index!' : 'ms_index!')
386+
if remove
387+
MSCleanUpJob.perform_later(record.ms_entries)
388+
else
389+
MSJob.perform_later(record, 'ms_index!')
390+
end
386391
end
387392
elsif options[:enqueue].respond_to?(:call)
388393
options[:enqueue]
@@ -454,7 +459,7 @@ def meilisearch(options = {}, &block)
454459
end
455460
end
456461
elsif respond_to?(:after_destroy)
457-
after_destroy { |searchable| searchable.ms_enqueue_remove_from_index!(ms_synchronous?) }
462+
after_destroy_commit { |searchable| searchable.ms_enqueue_remove_from_index!(ms_synchronous?) }
458463
end
459464
end
460465

@@ -563,6 +568,19 @@ def ms_index!(document, synchronous = false)
563568
end.compact
564569
end
565570

571+
def ms_entries_for(document:, synchronous:)
572+
primary_key = ms_primary_key_of(document)
573+
raise ArgumentError, 'Cannot index a record without a primary key' if primary_key.blank?
574+
575+
ms_configurations.filter_map do |options, settings|
576+
{
577+
synchronous: synchronous || options[:synchronous],
578+
index_uid: options[:index_uid],
579+
primary_key: primary_key
580+
}.with_indifferent_access unless ms_indexing_disabled?(options)
581+
end
582+
end
583+
566584
def ms_remove_from_index!(document, synchronous = false)
567585
return if ms_without_auto_index_scope
568586

@@ -940,6 +958,10 @@ def ms_synchronous?
940958
@ms_synchronous
941959
end
942960

961+
def ms_entries(synchronous = false)
962+
self.class.ms_entries_for(document: self, synchronous: synchronous || ms_synchronous?)
963+
end
964+
943965
private
944966

945967
def ms_mark_synchronous
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module MeiliSearch
2+
module Rails
3+
class MSCleanUpJob < ::ActiveJob::Base
4+
queue_as :meilisearch
5+
6+
def perform(documents)
7+
documents.each do |document|
8+
index = MeiliSearch::Rails.client.index(document[:index_uid])
9+
10+
if document[:synchronous]
11+
index.delete_document!(document[:primary_key])
12+
else
13+
index.delete_document(document[:primary_key])
14+
end
15+
end
16+
end
17+
end
18+
end
19+
end

spec/integration_spec.rb

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,34 @@
628628
expect(results.size).to eq(1)
629629
end
630630

631+
describe '#ms_entries' do
632+
it 'returns all 3 indexes for a public book' do
633+
book = Book.create!(
634+
name: 'Frankenstein', author: 'Mary Shelley',
635+
premium: false, released: true
636+
)
637+
638+
expect(book.ms_entries).to contain_exactly(
639+
a_hash_including("index_uid" => safe_index_uid('SecuredBook')),
640+
a_hash_including("index_uid" => safe_index_uid('BookAuthor')),
641+
a_hash_including("index_uid" => safe_index_uid('Book')),
642+
)
643+
end
644+
645+
it 'returns all 3 indexes for a non-public book' do
646+
book = Book.create!(
647+
name: 'Frankenstein', author: 'Mary Shelley',
648+
premium: false, released: false
649+
)
650+
651+
expect(book.ms_entries).to contain_exactly(
652+
a_hash_including("index_uid" => safe_index_uid('SecuredBook')),
653+
a_hash_including("index_uid" => safe_index_uid('BookAuthor')),
654+
a_hash_including("index_uid" => safe_index_uid('Book')),
655+
)
656+
end
657+
end
658+
631659
it 'returns facets using max values per facet' do
632660
10.times do
633661
Book.create! name: Faker::Book.title, author: Faker::Book.author, genre: Faker::Book.genre
@@ -932,7 +960,10 @@
932960
end
933961

934962
describe 'ConditionallyEnqueuedDocument' do
935-
before { allow(MeiliSearch::Rails::MSJob).to receive(:perform_later).and_return(nil) }
963+
before do
964+
allow(MeiliSearch::Rails::MSJob).to receive(:perform_later).and_return(nil)
965+
allow(MeiliSearch::Rails::MSCleanUpJob).to receive(:perform_later).and_return(nil)
966+
end
936967

937968
it 'does not try to enqueue an index job when :if option resolves to false' do
938969
doc = ConditionallyEnqueuedDocument.create! name: 'test', is_public: false
@@ -952,7 +983,7 @@
952983

953984
doc.destroy!
954985

955-
expect(MeiliSearch::Rails::MSJob).to have_received(:perform_later).with(doc, 'ms_remove_from_index!')
986+
expect(MeiliSearch::Rails::MSCleanUpJob).to have_received(:perform_later).with(doc.ms_entries)
956987
end
957988
end
958989
end
@@ -1049,6 +1080,19 @@
10491080

10501081
expect(cat_index).to eq(dog_index)
10511082
end
1083+
1084+
describe '#ms_entries' do
1085+
it 'returns the correct entry for each animal' do
1086+
toby_dog = Dog.create!(name: 'Toby the Dog')
1087+
taby_cat = Cat.create!(name: 'Taby the Cat')
1088+
1089+
expect(toby_dog.ms_entries).to contain_exactly(
1090+
a_hash_including('primary_key' => /dog_\d+/))
1091+
1092+
expect(taby_cat.ms_entries).to contain_exactly(
1093+
a_hash_including('primary_key' => /cat_\d+/))
1094+
end
1095+
end
10521096
end
10531097

10541098
describe 'Songs' do

spec/ms_clean_up_job_spec.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
require 'spec_helper'
2+
3+
RSpec.describe 'MeiliSearch::Rails::MSCleanUpJob' do
4+
include ActiveJob::TestHelper
5+
6+
def clean_up_indexes
7+
indexes.each(&:delete_all_documents)
8+
end
9+
10+
def create_indexed_record
11+
record
12+
13+
indexes.each do |index|
14+
index.wait_for_task(index.tasks['results'].last['uid'])
15+
end
16+
end
17+
18+
subject(:clean_up) { MeiliSearch::Rails::MSCleanUpJob }
19+
20+
let(:record) do
21+
Book.create name: "Moby Dick", author: "Herman Mellville",
22+
premium: false, released: true
23+
end
24+
25+
let(:record_entries) do
26+
record.ms_entries(true).each { |h| h[:index_uid] += '_test' }
27+
end
28+
29+
let(:indexes) do
30+
%w[SecuredBook BookAuthor Book].map do |uid|
31+
Book.index(safe_index_uid uid)
32+
end
33+
end
34+
35+
it 'removes record from all indexes' do
36+
clean_up_indexes
37+
38+
create_indexed_record
39+
40+
clean_up.perform_now(record_entries)
41+
42+
indexes.each do |index|
43+
expect(index.search('*')['hits']).to be_empty
44+
end
45+
end
46+
47+
context 'when record is already destroyed' do
48+
subject(:record) do
49+
Restaurant.create(
50+
name: "Los Pollos Hermanos",
51+
kind: "Mexican",
52+
description: "Mexican chicken restaurant in Albuquerque, New Mexico.")
53+
end
54+
55+
let(:indexes) { [Restaurant.index] }
56+
57+
it 'successfully deletes its document in the index' do
58+
clean_up_indexes
59+
60+
create_indexed_record
61+
62+
record.delete # does not run callbacks, unlike #destroy
63+
64+
clean_up.perform_later(record_entries)
65+
expect { perform_enqueued_jobs }.not_to raise_error
66+
67+
indexes.each do |index|
68+
expect(index.search('*')['hits']).to be_empty
69+
end
70+
end
71+
end
72+
end

spec/ms_job_spec.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require 'spec_helper'
22

33
RSpec.describe 'MeiliSearch::Rails::MSJob' do
4+
include ActiveJob::TestHelper
5+
46
subject(:job) { MeiliSearch::Rails::MSJob }
57

68
let(:record) { double }

0 commit comments

Comments
 (0)