searchkick 4.4.0 → 5.3.1

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.
@@ -2,16 +2,20 @@ module Searchkick
2
2
  class BulkReindexJob < ActiveJob::Base
3
3
  queue_as { Searchkick.queue_name }
4
4
 
5
+ # TODO remove min_id and max_id in Searchkick 6
5
6
  def perform(class_name:, record_ids: nil, index_name: nil, method_name: nil, batch_id: nil, min_id: nil, max_id: nil)
6
- klass = class_name.constantize
7
- index = index_name ? Searchkick::Index.new(index_name, **klass.searchkick_options) : klass.searchkick_index
7
+ model = Searchkick.load_model(class_name)
8
+ index = model.searchkick_index(name: index_name)
9
+
10
+ # legacy
8
11
  record_ids ||= min_id..max_id
9
- index.import_scope(
10
- Searchkick.load_records(klass, record_ids),
11
- method_name: method_name,
12
- batch: true,
13
- batch_id: batch_id
14
- )
12
+
13
+ relation = Searchkick.scope(model)
14
+ relation = Searchkick.load_records(relation, record_ids)
15
+ relation = relation.search_import if relation.respond_to?(:search_import)
16
+
17
+ RecordIndexer.new(index).reindex(relation, mode: :inline, method_name: method_name, full: false)
18
+ RelationIndexer.new(index).batch_completed(batch_id) if batch_id
15
19
  end
16
20
  end
17
21
  end
@@ -0,0 +1,40 @@
1
+ # based on https://gist.github.com/mnutt/566725
2
+ module Searchkick
3
+ module ControllerRuntime
4
+ extend ActiveSupport::Concern
5
+
6
+ protected
7
+
8
+ attr_internal :searchkick_runtime
9
+
10
+ def process_action(action, *args)
11
+ # We also need to reset the runtime before each action
12
+ # because of queries in middleware or in cases we are streaming
13
+ # and it won't be cleaned up by the method below.
14
+ Searchkick::LogSubscriber.reset_runtime
15
+ super
16
+ end
17
+
18
+ def cleanup_view_runtime
19
+ searchkick_rt_before_render = Searchkick::LogSubscriber.reset_runtime
20
+ runtime = super
21
+ searchkick_rt_after_render = Searchkick::LogSubscriber.reset_runtime
22
+ self.searchkick_runtime = searchkick_rt_before_render + searchkick_rt_after_render
23
+ runtime - searchkick_rt_after_render
24
+ end
25
+
26
+ def append_info_to_payload(payload)
27
+ super
28
+ payload[:searchkick_runtime] = (searchkick_runtime || 0) + Searchkick::LogSubscriber.reset_runtime
29
+ end
30
+
31
+ module ClassMethods
32
+ def log_process_action(payload)
33
+ messages = super
34
+ runtime = payload[:searchkick_runtime]
35
+ messages << ("Searchkick: %.1fms" % runtime.to_f) if runtime.to_f > 0
36
+ messages
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,9 +1,5 @@
1
- require "searchkick/index_options"
2
-
3
1
  module Searchkick
4
2
  class Index
5
- include IndexOptions
6
-
7
3
  attr_reader :name, :options
8
4
 
9
5
  def initialize(name, options = {})
@@ -12,6 +8,10 @@ module Searchkick
12
8
  @klass_document_type = {} # cache
13
9
  end
14
10
 
11
+ def index_options
12
+ IndexOptions.new(self).index_options
13
+ end
14
+
15
15
  def create(body = {})
16
16
  client.indices.create index: name, body: body
17
17
  end
@@ -38,12 +38,15 @@ module Searchkick
38
38
  client.indices.exists_alias name: name
39
39
  end
40
40
 
41
+ # call to_h for consistent results between elasticsearch gem 7 and 8
42
+ # could do for all API calls, but just do for ones where return value is focus for now
41
43
  def mapping
42
- client.indices.get_mapping index: name
44
+ client.indices.get_mapping(index: name).to_h
43
45
  end
44
46
 
47
+ # call to_h for consistent results between elasticsearch gem 7 and 8
45
48
  def settings
46
- client.indices.get_settings index: name
49
+ client.indices.get_settings(index: name).to_h
47
50
  end
48
51
 
49
52
  def refresh_interval
@@ -64,16 +67,17 @@ module Searchkick
64
67
  index: name,
65
68
  body: {
66
69
  query: {match_all: {}},
67
- size: 0
70
+ size: 0,
71
+ track_total_hits: true
68
72
  }
69
73
  )
70
74
 
71
- Searchkick::Results.new(nil, response).total_count
75
+ Results.new(nil, response).total_count
72
76
  end
73
77
 
74
78
  def promote(new_name, update_refresh_interval: false)
75
79
  if update_refresh_interval
76
- new_index = Searchkick::Index.new(new_name, @options)
80
+ new_index = Index.new(new_name, @options)
77
81
  settings = options[:settings] || {}
78
82
  refresh_interval = (settings[:index] && settings[:index][:refresh_interval]) || "1s"
79
83
  new_index.update_settings(index: {refresh_interval: refresh_interval})
@@ -82,7 +86,8 @@ module Searchkick
82
86
  old_indices =
83
87
  begin
84
88
  client.indices.get_alias(name: name).keys
85
- rescue Elasticsearch::Transport::Transport::Errors::NotFound
89
+ rescue => e
90
+ raise e unless Searchkick.not_found_error?(e)
86
91
  {}
87
92
  end
88
93
  actions = old_indices.map { |old_name| {remove: {index: old_name, alias: name}} } + [{add: {index: new_name, alias: name}}]
@@ -94,7 +99,7 @@ module Searchkick
94
99
  record_data = RecordData.new(self, record).record_data
95
100
 
96
101
  # remove underscore
97
- get_options = Hash[record_data.map { |k, v| [k.to_s.sub(/\A_/, "").to_sym, v] }]
102
+ get_options = record_data.to_h { |k, v| [k.to_s.sub(/\A_/, "").to_sym, v] }
98
103
 
99
104
  client.get(get_options)["_source"]
100
105
  end
@@ -103,11 +108,12 @@ module Searchkick
103
108
  indices =
104
109
  begin
105
110
  if client.indices.respond_to?(:get_alias)
106
- client.indices.get_alias
111
+ client.indices.get_alias(index: "#{name}*")
107
112
  else
108
113
  client.indices.get_aliases
109
114
  end
110
- rescue Elasticsearch::Transport::Transport::Errors::NotFound
115
+ rescue => e
116
+ raise e unless Searchkick.not_found_error?(e)
111
117
  {}
112
118
  end
113
119
  indices = indices.select { |_k, v| v.empty? || v["aliases"].empty? } if unaliased
@@ -118,37 +124,52 @@ module Searchkick
118
124
  def clean_indices
119
125
  indices = all_indices(unaliased: true)
120
126
  indices.each do |index|
121
- Searchkick::Index.new(index).delete
127
+ Index.new(index).delete
122
128
  end
123
129
  indices
124
130
  end
125
131
 
126
- # record based
127
- # use helpers for notifications
128
-
129
132
  def store(record)
130
- bulk_indexer.bulk_index([record])
133
+ notify(record, "Store") do
134
+ queue_index([record])
135
+ end
131
136
  end
132
137
 
133
138
  def remove(record)
134
- bulk_indexer.bulk_delete([record])
139
+ notify(record, "Remove") do
140
+ queue_delete([record])
141
+ end
135
142
  end
136
143
 
137
144
  def update_record(record, method_name)
138
- bulk_indexer.bulk_update([record], method_name)
145
+ notify(record, "Update") do
146
+ queue_update([record], method_name)
147
+ end
139
148
  end
140
149
 
141
150
  def bulk_delete(records)
142
- bulk_indexer.bulk_delete(records)
151
+ return if records.empty?
152
+
153
+ notify_bulk(records, "Delete") do
154
+ queue_delete(records)
155
+ end
143
156
  end
144
157
 
145
158
  def bulk_index(records)
146
- bulk_indexer.bulk_index(records)
159
+ return if records.empty?
160
+
161
+ notify_bulk(records, "Import") do
162
+ queue_index(records)
163
+ end
147
164
  end
148
165
  alias_method :import, :bulk_index
149
166
 
150
167
  def bulk_update(records, method_name)
151
- bulk_indexer.bulk_update(records, method_name)
168
+ return if records.empty?
169
+
170
+ notify_bulk(records, "Update") do
171
+ queue_update(records, method_name)
172
+ end
152
173
  end
153
174
 
154
175
  def search_id(record)
@@ -160,78 +181,96 @@ module Searchkick
160
181
  end
161
182
 
162
183
  def similar_record(record, **options)
163
- like_text = retrieve(record).to_hash
164
- .keep_if { |k, _| !options[:fields] || options[:fields].map(&:to_s).include?(k) }
165
- .values.compact.join(" ")
166
-
167
- options[:where] ||= {}
168
- options[:where][:_id] ||= {}
169
- options[:where][:_id][:not] = Array(options[:where][:_id][:not]) + [record.id.to_s]
170
184
  options[:per_page] ||= 10
171
- options[:similar] = true
185
+ options[:similar] = [RecordData.new(self, record).record_data]
186
+ options[:models] ||= [record.class] unless options.key?(:model)
172
187
 
173
- # TODO use index class instead of record class
174
- Searchkick.search(like_text, model: record.class, **options)
188
+ Searchkick.search("*", **options)
175
189
  end
176
190
 
177
191
  def reload_synonyms
178
- require "elasticsearch/xpack"
179
- raise Error, "Requires Elasticsearch 7.3+" if Searchkick.server_below?("7.3.0")
180
- raise Error, "Requires elasticsearch-xpack 7.8+" unless client.xpack.respond_to?(:indices)
181
- client.xpack.indices.reload_search_analyzers(index: name)
192
+ if Searchkick.opensearch?
193
+ client.transport.perform_request "POST", "_plugins/_refresh_search_analyzers/#{CGI.escape(name)}"
194
+ else
195
+ raise Error, "Requires Elasticsearch 7.3+" if Searchkick.server_below?("7.3.0")
196
+ begin
197
+ client.transport.perform_request("GET", "#{CGI.escape(name)}/_reload_search_analyzers")
198
+ rescue => e
199
+ raise Error, "Requires non-OSS version of Elasticsearch" if Searchkick.not_allowed_error?(e)
200
+ raise e
201
+ end
202
+ end
182
203
  end
183
204
 
184
205
  # queue
185
206
 
186
207
  def reindex_queue
187
- Searchkick::ReindexQueue.new(name)
208
+ ReindexQueue.new(name)
188
209
  end
189
210
 
190
211
  # reindex
191
212
 
192
- def reindex(relation, method_name, scoped:, full: false, scope: nil, **options)
213
+ # note: this is designed to be used internally
214
+ # so it does not check object matches index class
215
+ def reindex(object, method_name: nil, full: false, **options)
216
+ if object.is_a?(Array)
217
+ # note: purposefully skip full
218
+ return reindex_records(object, method_name: method_name, **options)
219
+ end
220
+
221
+ if !object.respond_to?(:searchkick_klass)
222
+ raise Error, "Cannot reindex object"
223
+ end
224
+
225
+ scoped = Searchkick.relation?(object)
226
+ # call searchkick_klass for inheritance
227
+ relation = scoped ? object.all : Searchkick.scope(object.searchkick_klass).all
228
+
193
229
  refresh = options.fetch(:refresh, !scoped)
194
230
  options.delete(:refresh)
195
231
 
196
- if method_name
197
- # TODO throw ArgumentError
198
- Searchkick.warn("unsupported keywords: #{options.keys.map(&:inspect).join(", ")}") if options.any?
232
+ if method_name || (scoped && !full)
233
+ mode = options.delete(:mode) || :inline
234
+ raise ArgumentError, "unsupported keywords: #{options.keys.map(&:inspect).join(", ")}" if options.any?
199
235
 
200
- # update
201
- import_scope(relation, method_name: method_name, scope: scope)
202
- self.refresh if refresh
203
- true
204
- elsif scoped && !full
205
- # TODO throw ArgumentError
206
- Searchkick.warn("unsupported keywords: #{options.keys.map(&:inspect).join(", ")}") if options.any?
207
-
208
- # reindex association
209
- import_scope(relation, scope: scope)
236
+ # import only
237
+ import_scope(relation, method_name: method_name, mode: mode)
210
238
  self.refresh if refresh
211
239
  true
212
240
  else
213
- # full reindex
214
- reindex_scope(relation, scope: scope, **options)
241
+ async = options.delete(:async)
242
+ if async
243
+ if async.is_a?(Hash) && async[:wait]
244
+ # TODO warn in 5.1
245
+ # Searchkick.warn "async option is deprecated - use mode: :async, wait: true instead"
246
+ options[:wait] = true unless options.key?(:wait)
247
+ else
248
+ # TODO warn in 5.1
249
+ # Searchkick.warn "async option is deprecated - use mode: :async instead"
250
+ end
251
+ options[:mode] ||= :async
252
+ end
253
+
254
+ full_reindex(relation, **options)
215
255
  end
216
256
  end
217
257
 
218
258
  def create_index(index_options: nil)
219
259
  index_options ||= self.index_options
220
- index = Searchkick::Index.new("#{name}_#{Time.now.strftime('%Y%m%d%H%M%S%L')}", @options)
260
+ index = Index.new("#{name}_#{Time.now.strftime('%Y%m%d%H%M%S%L')}", @options)
221
261
  index.create(index_options)
222
262
  index
223
263
  end
224
264
 
225
265
  def import_scope(relation, **options)
226
- bulk_indexer.import_scope(relation, **options)
266
+ relation_indexer.reindex(relation, **options)
227
267
  end
228
268
 
229
269
  def batches_left
230
- bulk_indexer.batches_left
270
+ relation_indexer.batches_left
231
271
  end
232
272
 
233
- # other
234
-
273
+ # private
235
274
  def klass_document_type(klass, ignore_type = false)
236
275
  @klass_document_type[[klass, ignore_type]] ||= begin
237
276
  if !ignore_type && klass.searchkick_klass.searchkick_options[:_type]
@@ -244,7 +283,7 @@ module Searchkick
244
283
  end
245
284
  end
246
285
 
247
- # should not be public
286
+ # private
248
287
  def conversions_fields
249
288
  @conversions_fields ||= begin
250
289
  conversions = Array(options[:conversions])
@@ -252,10 +291,12 @@ module Searchkick
252
291
  end
253
292
  end
254
293
 
294
+ # private
255
295
  def suggest_fields
256
296
  @suggest_fields ||= Array(options[:suggest]).map(&:to_s)
257
297
  end
258
298
 
299
+ # private
259
300
  def locations_fields
260
301
  @locations_fields ||= begin
261
302
  locations = Array(options[:locations])
@@ -274,8 +315,20 @@ module Searchkick
274
315
  Searchkick.client
275
316
  end
276
317
 
277
- def bulk_indexer
278
- @bulk_indexer ||= BulkIndexer.new(self)
318
+ def queue_index(records)
319
+ Searchkick.indexer.queue(records.map { |r| RecordData.new(self, r).index_data })
320
+ end
321
+
322
+ def queue_delete(records)
323
+ Searchkick.indexer.queue(records.reject { |r| r.id.blank? }.map { |r| RecordData.new(self, r).delete_data })
324
+ end
325
+
326
+ def queue_update(records, method_name)
327
+ Searchkick.indexer.queue(records.map { |r| RecordData.new(self, r).update_data(method_name) })
328
+ end
329
+
330
+ def relation_indexer
331
+ @relation_indexer ||= RelationIndexer.new(self)
279
332
  end
280
333
 
281
334
  def index_settings
@@ -286,13 +339,24 @@ module Searchkick
286
339
  index.import_scope(relation, **import_options)
287
340
  end
288
341
 
342
+ def reindex_records(object, mode: nil, refresh: false, **options)
343
+ mode ||= Searchkick.callbacks_value || @options[:callbacks] || :inline
344
+ mode = :inline if mode == :bulk
345
+
346
+ result = RecordIndexer.new(self).reindex(object, mode: mode, full: false, **options)
347
+ self.refresh if refresh
348
+ result
349
+ end
350
+
289
351
  # https://gist.github.com/jarosan/3124884
290
352
  # http://www.elasticsearch.org/blog/changing-mapping-with-zero-downtime/
291
- def reindex_scope(relation, import: true, resume: false, retain: false, async: false, refresh_interval: nil, scope: nil)
353
+ def full_reindex(relation, import: true, resume: false, retain: false, mode: nil, refresh_interval: nil, scope: nil, wait: nil)
354
+ raise ArgumentError, "wait only available in :async mode" if !wait.nil? && mode != :async
355
+
292
356
  if resume
293
357
  index_name = all_indices.sort.last
294
- raise Searchkick::Error, "No index to resume" unless index_name
295
- index = Searchkick::Index.new(index_name, @options)
358
+ raise Error, "No index to resume" unless index_name
359
+ index = Index.new(index_name, @options)
296
360
  else
297
361
  clean_indices unless retain
298
362
 
@@ -302,9 +366,9 @@ module Searchkick
302
366
  end
303
367
 
304
368
  import_options = {
305
- resume: resume,
306
- async: async,
369
+ mode: (mode || :inline),
307
370
  full: true,
371
+ resume: resume,
308
372
  scope: scope
309
373
  }
310
374
 
@@ -316,7 +380,7 @@ module Searchkick
316
380
  import_before_promotion(index, relation, **import_options) if import
317
381
 
318
382
  # get existing indices to remove
319
- unless async
383
+ unless mode == :async
320
384
  check_uuid(uuid, index.uuid)
321
385
  promote(index.name, update_refresh_interval: !refresh_interval.nil?)
322
386
  clean_indices unless retain
@@ -329,8 +393,8 @@ module Searchkick
329
393
  index.import_scope(relation, **import_options) if import
330
394
  end
331
395
 
332
- if async
333
- if async.is_a?(Hash) && async[:wait]
396
+ if mode == :async
397
+ if wait
334
398
  puts "Created index: #{index.name}"
335
399
  puts "Jobs queued. Waiting..."
336
400
  loop do
@@ -354,9 +418,9 @@ module Searchkick
354
418
  index.refresh
355
419
  true
356
420
  end
357
- rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
358
- if e.message.include?("No handler for type [text]")
359
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
421
+ rescue => e
422
+ if Searchkick.transport_error?(e) && (e.message.include?("No handler for type [text]") || e.message.include?("class java.util.ArrayList cannot be cast to class java.util.Map"))
423
+ raise UnsupportedVersionError
360
424
  end
361
425
 
362
426
  raise e
@@ -368,7 +432,36 @@ module Searchkick
368
432
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#index-creation
369
433
  def check_uuid(old_uuid, new_uuid)
370
434
  if old_uuid != new_uuid
371
- raise Searchkick::Error, "Safety check failed - only run one Model.reindex per model at a time"
435
+ raise Error, "Safety check failed - only run one Model.reindex per model at a time"
436
+ end
437
+ end
438
+
439
+ def notify(record, name)
440
+ if Searchkick.callbacks_value == :bulk
441
+ yield
442
+ else
443
+ name = "#{record.class.searchkick_klass.name} #{name}" if record && record.class.searchkick_klass
444
+ event = {
445
+ name: name,
446
+ id: search_id(record)
447
+ }
448
+ ActiveSupport::Notifications.instrument("request.searchkick", event) do
449
+ yield
450
+ end
451
+ end
452
+ end
453
+
454
+ def notify_bulk(records, name)
455
+ if Searchkick.callbacks_value == :bulk
456
+ yield
457
+ else
458
+ event = {
459
+ name: "#{records.first.class.searchkick_klass.name} #{name}",
460
+ count: records.size
461
+ }
462
+ ActiveSupport::Notifications.instrument("request.searchkick", event) do
463
+ yield
464
+ end
372
465
  end
373
466
  end
374
467
  end
@@ -0,0 +1,30 @@
1
+ module Searchkick
2
+ class IndexCache
3
+ def initialize(max_size: 20)
4
+ @data = {}
5
+ @mutex = Mutex.new
6
+ @max_size = max_size
7
+ end
8
+
9
+ # probably a better pattern for this
10
+ # but keep it simple
11
+ def fetch(name)
12
+ # thread-safe in MRI without mutex
13
+ # due to how context switching works
14
+ @mutex.synchronize do
15
+ if @data.key?(name)
16
+ @data[name]
17
+ else
18
+ @data.clear if @data.size >= @max_size
19
+ @data[name] = yield
20
+ end
21
+ end
22
+ end
23
+
24
+ def clear
25
+ @mutex.synchronize do
26
+ @data.clear
27
+ end
28
+ end
29
+ end
30
+ end