full_search 0.4.5 → 0.4.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fccc79c0dbfc8eeb24424e156c9be822470e2bbc3ecfdb75539f8a70010b3d38
4
- data.tar.gz: 3e50db78f978cb095834dd9f0d58802dfb1de69f9c80e04fc6c18cbdf1d9ccef
3
+ metadata.gz: a47cd95db7244715db988cdce15e8466dc2131d3a995769071ca7b513f7b91e4
4
+ data.tar.gz: 35303e2f06de22ccfcd1e20018d8f1a83cd1d32bd2d883cdd7ba60c1919d3541
5
5
  SHA512:
6
- metadata.gz: 239eb848fe5631b1a8212840246ccd6be8387642b8f269687ae5052dc765afd25ccb49bd55c395b760aa772eb2da696bf786a2155f451326dbd8908da4f95532
7
- data.tar.gz: bd01ae3b672adc80f97c5ee70699444d773cb2a66c8ab2b98133f3528cba5099b3750fea9682cd6cdb4315e0fac17b7781b720a60f6c79519bf1857b78324aa4
6
+ metadata.gz: 1316924b824ef6814d2899ee165426b8eee7b021fc7bde0ee358f6a9066f2668561f27fb1de12f12bd0d3b836f91a2d428eb1c9da9e0db7dc494cf037ea707e5
7
+ data.tar.gz: 3c359e74e2898302ef51ada7f864c22c1b2dd340fb27e5e097b59baa78bae97b800d74ad020f172f202c2da85d3094925aba3fbad9cdc00dc5929fd990803dab
@@ -19,6 +19,19 @@ module FullSearch
19
19
  models_in_bulk_import.delete(model)
20
20
  FullSearch::Index.create_triggers!(model)
21
21
  FullSearch::BackfillJob.perform_later(model.name)
22
+ rescue => e
23
+ # Triggers are still dropped at this point; the FTS index will rot on
24
+ # every subsequent write. Retry once, then raise loudly so the caller
25
+ # knows the index is compromised instead of failing silent.
26
+ retry_count = (Thread.current[:_fs_bulk_import_retry] ||= 0)
27
+ if retry_count < 1
28
+ Thread.current[:_fs_bulk_import_retry] = retry_count + 1
29
+ retry
30
+ end
31
+ raise FullSearch::TriggerRestoreError,
32
+ "Failed to restore FTS triggers for #{model.table_name} after bulk import: #{e.message}"
33
+ ensure
34
+ Thread.current[:_fs_bulk_import_retry] = nil
22
35
  end
23
36
 
24
37
  def bulk_importing?(model)
@@ -10,4 +10,5 @@ module FullSearch
10
10
  class UnsupportedDatabaseError < Error; end
11
11
  class MissingTableError < Error; end
12
12
  class InvalidQueryError < Error; end
13
+ class TriggerRestoreError < Error; end
13
14
  end
@@ -167,8 +167,8 @@ module FullSearch
167
167
 
168
168
  conn = connection
169
169
  begin
170
- conn.execute("DELETE FROM #{qt(fts_table_name(model))};")
171
- conn.execute("DELETE FROM #{qt(trigram_table_name(model))};") if trigram_table_needed?(model) && trigram_table_exists?(model)
170
+ with_busy_retry { conn.execute("DELETE FROM #{qt(fts_table_name(model))};") }
171
+ with_busy_retry { conn.execute("DELETE FROM #{qt(trigram_table_name(model))};") } if trigram_table_needed?(model) && trigram_table_exists?(model)
172
172
  rescue ActiveRecord::StatementInvalid => e
173
173
  raise unless e.message.match?(/fts5: corruption|invalid fts5 file format|malformed database schema/i)
174
174
 
@@ -191,12 +191,12 @@ module FullSearch
191
191
  # drop the shadow tables directly, then drop the (now-empty) virtual
192
192
  # table wrapper, so the caller can recreate a clean index.
193
193
  def drop_fts_table_safely!(table_name)
194
- connection.execute("DROP TABLE IF EXISTS #{qt(table_name)};")
194
+ with_busy_retry { connection.execute("DROP TABLE IF EXISTS #{qt(table_name)};") }
195
195
  rescue ActiveRecord::StatementInvalid => e
196
196
  raise unless e.message.match?(/fts5: corruption|invalid fts5 file format|malformed database schema/i)
197
197
 
198
198
  drop_shadow_tables!(table_name)
199
- connection.execute("DROP TABLE IF EXISTS #{qt(table_name)};")
199
+ with_busy_retry { connection.execute("DROP TABLE IF EXISTS #{qt(table_name)};") }
200
200
  end
201
201
 
202
202
  # FTS5 stores its index data in a fixed set of shadow tables named
@@ -249,12 +249,14 @@ module FullSearch
249
249
  def healthy?(model)
250
250
  return false unless table_exists?(model)
251
251
 
252
- connection.execute("SELECT count(*) FROM #{qt(fts_table_name(model))}")
253
- true
254
- rescue ActiveRecord::StatementInvalid => e
255
- raise unless e.message.match?(/fts5: corruption|invalid fts5 file format|malformed database schema/i)
252
+ begin
253
+ with_busy_retry { connection.execute("SELECT count(*) FROM #{qt(fts_table_name(model))}") }
254
+ true
255
+ rescue ActiveRecord::StatementInvalid => e
256
+ raise unless e.message.match?(/fts5: corruption|invalid fts5 file format|malformed database schema/i)
256
257
 
257
- false
258
+ false
259
+ end
258
260
  end
259
261
 
260
262
  def trigram_table_needed?(model)
@@ -267,8 +269,40 @@ module FullSearch
267
269
  ).any?
268
270
  end
269
271
 
272
+ def missing_triggers?(model)
273
+ return false if FullSearch.bulk_importing?(model)
274
+ return false unless model_table_exists?(model)
275
+ return false unless table_exists?(model)
276
+
277
+ IndexCache.fetch("missing_triggers:#{model.table_name}") do
278
+ existing = connection.execute(
279
+ "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
280
+ ).map { |r| r["name"] }
281
+
282
+ expected = trigger_names(model)
283
+ expected += trigram_trigger_names(model) if trigram_table_needed?(model)
284
+ (expected - existing).any?
285
+ end
286
+ end
287
+
270
288
  private
271
289
 
290
+ BUSY_RETRIES = 3
291
+ BUSY_BACKOFF = 0.1
292
+
293
+ def with_busy_retry
294
+ attempts = 0
295
+ begin
296
+ yield
297
+ rescue ActiveRecord::StatementInvalid => e
298
+ raise unless e.message.match?(/database is locked/i) && attempts < BUSY_RETRIES
299
+ attempts += 1
300
+ sleep(BUSY_BACKOFF * attempts)
301
+ IndexCache.clear!
302
+ retry
303
+ end
304
+ end
305
+
272
306
  def connection
273
307
  ActiveRecord::Base.connection
274
308
  end
@@ -290,9 +324,11 @@ module FullSearch
290
324
  end
291
325
 
292
326
  def model_table_exists?(model)
293
- connection.execute(
294
- "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
295
- ).any?
327
+ IndexCache.fetch("model_table_exists:#{model.table_name}") do
328
+ connection.execute(
329
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=#{q(model.table_name)} LIMIT 1"
330
+ ).any?
331
+ end
296
332
  end
297
333
 
298
334
  def table_exists?(model)
@@ -354,16 +390,7 @@ module FullSearch
354
390
 
355
391
  def ensure_triggers!(model)
356
392
  return if FullSearch.bulk_importing?(model)
357
-
358
- existing = connection.execute(
359
- "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=#{q(model.table_name)}"
360
- ).map { |r| r["name"] }
361
-
362
- expected = trigger_names(model)
363
- expected += trigram_trigger_names(model) if trigram_table_needed?(model)
364
- return if (expected - existing).empty? && (existing - expected).empty?
365
-
366
- rebuild!(model)
393
+ rebuild!(model) if missing_triggers?(model)
367
394
  end
368
395
 
369
396
  def trigger_names(model)
@@ -378,6 +405,8 @@ module FullSearch
378
405
  %W[#{base}_ai #{base}_ad #{base}_au]
379
406
  end
380
407
 
408
+ private
409
+
381
410
  def insert_trigger_sql(model)
382
411
  dsl = model.full_search_dsl
383
412
  cols = dsl.fields + dsl.filters + extra_columns(model)
@@ -12,40 +12,42 @@ module FullSearch
12
12
  end
13
13
 
14
14
  def call
15
- searched = groups.map do |group|
16
- model = fetch(group, :model)
17
- raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
15
+ IndexCache.with_cache do
16
+ searched = groups.map do |group|
17
+ model = fetch(group, :model)
18
+ raise FullSearch::NotConfiguredError, "#{model} is not full_search configured" unless model.full_search_dsl
18
19
 
19
- filters = group[:filters] || {}
20
- limit = positive_integer(group[:limit], 8)
21
- offset = positive_integer(group[:offset], 0)
22
- raw_limit = limit + 1
20
+ filters = group[:filters] || {}
21
+ limit = positive_integer(group[:limit], 8)
22
+ offset = positive_integer(group[:offset], 0)
23
+ raw_limit = limit + 1
23
24
 
24
- relation = model.full_search(
25
- query,
26
- filters: filters,
27
- limit: raw_limit,
28
- offset: offset,
29
- highlight: group[:highlight],
30
- highlight_fields: group[:highlight_fields],
31
- matching_strategy: group[:matching_strategy],
32
- per_strategy_limit: group[:per_strategy_limit],
33
- scope: group[:scope],
34
- includes: group[:includes]
35
- )
25
+ relation = model.full_search(
26
+ query,
27
+ filters: filters,
28
+ limit: raw_limit,
29
+ offset: offset,
30
+ highlight: group[:highlight],
31
+ highlight_fields: group[:highlight_fields],
32
+ matching_strategy: group[:matching_strategy],
33
+ per_strategy_limit: group[:per_strategy_limit],
34
+ scope: group[:scope],
35
+ includes: group[:includes]
36
+ )
36
37
 
37
- records = relation.to_a
38
- has_more = records.size > limit
39
- records = records.first(limit) if has_more
38
+ records = relation.to_a
39
+ has_more = records.size > limit
40
+ records = records.first(limit) if has_more
40
41
 
41
- group.slice(:key, :label, :icon, :model).merge(
42
- results: records,
43
- has_more: has_more,
44
- total_count: records.size
45
- )
46
- end
42
+ group.slice(:key, :label, :icon, :model).merge(
43
+ results: records,
44
+ has_more: has_more,
45
+ total_count: records.size
46
+ )
47
+ end
47
48
 
48
- {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
49
+ {groups: searched, total_count: searched.sum { |g| g[:total_count] }}
50
+ end
49
51
  end
50
52
 
51
53
  private
@@ -92,6 +92,21 @@ module FullSearch
92
92
  raise MissingTableError, "FTS table `#{FullSearch::Index.fts_table_name(model)}` does not exist. Run `bin/rails full_search:prepare` to create it."
93
93
  end
94
94
 
95
+ if FullSearch::Index.missing_triggers?(model)
96
+ if FullSearch.config.auto_rebuild_on_stale_query
97
+ FullSearch::Index.rebuild!(model)
98
+ return
99
+ end
100
+
101
+ case FullSearch.config.stale_query_behavior
102
+ when :raise
103
+ raise ConfigChangedError, "FTS triggers for #{model.table_name} are missing; run full_search:rebuild"
104
+ when :log_and_fallback
105
+ Rails.logger.warn("[full_search] FTS triggers for #{model.table_name} are missing; results may be incomplete")
106
+ end
107
+ return
108
+ end
109
+
95
110
  stored = begin
96
111
  FullSearch::Index.stored_config_hash(model)
97
112
  rescue ActiveRecord::StatementInvalid
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FullSearch
4
- VERSION = "0.4.5"
4
+ VERSION = "0.4.7"
5
5
  end
@@ -105,7 +105,9 @@ namespace :full_search do
105
105
  ""
106
106
  end
107
107
 
108
- puts "#{model.table_name}: #{status}#{drift_info}"
108
+ trigger_info = FullSearch::Index.missing_triggers?(model) ? " | MISSING TRIGGERS" : ""
109
+
110
+ puts "#{model.table_name}: #{status}#{drift_info}#{trigger_info}"
109
111
  end
110
112
  end
111
113
 
@@ -120,6 +122,10 @@ namespace :full_search do
120
122
  next
121
123
  end
122
124
 
125
+ if FullSearch::Index.missing_triggers?(model)
126
+ unhealthy << "#{model.table_name}: missing FTS triggers"
127
+ end
128
+
123
129
  stored = FullSearch::Index.stored_config_hash(model)
124
130
  current = model.full_search_dsl.config_hash
125
131
  if stored != current
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: full_search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.4.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -189,7 +189,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
189
189
  - !ruby/object:Gem::Version
190
190
  version: '2.0'
191
191
  requirements: []
192
- rubygems_version: 4.0.15
192
+ rubygems_version: 4.0.10
193
193
  specification_version: 4
194
194
  summary: SQLite FTS5 full-text search for Rails/ActiveRecord
195
195
  test_files: []