search-engine-for-typesense 30.9.2 → 30.9.3

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +47 -13
  4. data/lib/search_engine/active_record_syncable.rb +85 -197
  5. data/lib/search_engine/ast/node.rb +3 -6
  6. data/lib/search_engine/async_partition_coordinator.rb +5 -3
  7. data/lib/search_engine/client/services/search.rb +2 -0
  8. data/lib/search_engine/client.rb +9 -3
  9. data/lib/search_engine/compiler.rb +33 -128
  10. data/lib/search_engine/deletion.rb +12 -9
  11. data/lib/search_engine/dsl/parser.rb +51 -71
  12. data/lib/search_engine/engine.rb +25 -16
  13. data/lib/search_engine/filters/sanitizer.rb +162 -126
  14. data/lib/search_engine/hydration/materializers.rb +53 -118
  15. data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
  16. data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
  17. data/lib/search_engine/indexing_run_store.rb +10 -0
  18. data/lib/search_engine/joins/guard.rb +14 -8
  19. data/lib/search_engine/multi.rb +92 -6
  20. data/lib/search_engine/multi_result.rb +40 -23
  21. data/lib/search_engine/notifications/compact_logger.rb +2 -2
  22. data/lib/search_engine/observability.rb +6 -6
  23. data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
  24. data/lib/search_engine/postgres_outbox/event.rb +12 -0
  25. data/lib/search_engine/postgres_outbox/repository.rb +48 -102
  26. data/lib/search_engine/postgres_outbox.rb +1 -0
  27. data/lib/search_engine/registry.rb +27 -28
  28. data/lib/search_engine/relation/compiler.rb +18 -33
  29. data/lib/search_engine/relation/dsl/filters.rb +71 -175
  30. data/lib/search_engine/relation/dx.rb +3 -1
  31. data/lib/search_engine/relation/materializers.rb +6 -3
  32. data/lib/search_engine/relation/options.rb +5 -26
  33. data/lib/search_engine/relation/state.rb +3 -2
  34. data/lib/search_engine/relation.rb +5 -4
  35. data/lib/search_engine/result.rb +33 -2
  36. data/lib/search_engine/schema.rb +23 -24
  37. data/lib/search_engine/test/offline_client.rb +2 -2
  38. data/lib/search_engine/test/stub_client.rb +9 -16
  39. data/lib/search_engine/update.rb +1 -1
  40. data/lib/search_engine/version.rb +1 -1
  41. data/lib/search_engine.rb +11 -149
  42. data/lib/tasks/search_engine.rake +3 -5
  43. metadata +17 -3
  44. /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
@@ -30,6 +30,12 @@ module SearchEngine
30
30
  @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
31
  end
32
32
 
33
+ # Single-target claims issue a fresh owner even when a caller reuses its worker id.
34
+ # Target-scoped repositories accept the caller's per-attempt owner.
35
+ def assigns_event_lease_owner?
36
+ !delivery_mode?
37
+ end
38
+
33
39
  # Claim pending rows for one worker and return event objects.
34
40
  # @param limit [Integer]
35
41
  # @param worker_id [String]
@@ -38,16 +44,15 @@ module SearchEngine
38
44
  return claim_pending_deliveries(limit: limit, worker_id: worker_id) if delivery_mode?
39
45
 
40
46
  reset_stale_processing!
41
- rows = []
42
-
43
47
  connection.transaction do
44
48
  rows = select_rows(claim_select_sql(limit))
49
+ next [] if rows.empty?
50
+
51
+ execute(supersede_older_pending_sql(rows))
45
52
  ids = rows.map { |row| row_value(row, :id) }
46
- execute(supersede_older_pending_sql(rows)) unless rows.empty?
47
- execute(claim_update_sql(ids, worker_id)) unless ids.empty?
53
+ owner = Event.new_lease_owner(worker_id)
54
+ select_rows(claim_update_sql(ids, owner)).map { |row| Event.new(row) }.sort_by { |event| event.id.to_i }
48
55
  end
49
-
50
- rows.map { |row| Event.new(row) }
51
56
  end
52
57
 
53
58
  # Reset timed-out processing rows to pending.
@@ -73,62 +78,37 @@ module SearchEngine
73
78
  SQL
74
79
  end
75
80
 
76
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
81
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
77
82
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
78
83
  def mark_processed!(events)
79
- return update_delivery_status!(events, 'processed', extra: PROCESSED_AT_ASSIGNMENT) if delivery_mode?
80
-
81
- update_status!(events, 'processed', extra: PROCESSED_AT_ASSIGNMENT)
84
+ acknowledge!(events) { "status = 'processed', #{PROCESSED_AT_ASSIGNMENT}" }
82
85
  end
83
86
 
84
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
87
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
85
88
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
86
89
  def mark_superseded!(events)
87
- return update_delivery_status!(events, 'superseded', extra: PROCESSED_AT_ASSIGNMENT) if delivery_mode?
88
-
89
- update_status!(events, 'superseded', extra: PROCESSED_AT_ASSIGNMENT)
90
+ acknowledge!(events) { "status = 'superseded', #{PROCESSED_AT_ASSIGNMENT}" }
90
91
  end
91
92
 
92
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
93
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
93
94
  # @param error [Exception, String]
94
95
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
95
96
  def mark_retryable!(events, error:)
96
- ids = event_ids(events)
97
- return [] if ids.empty?
98
- return mark_delivery_retryable!(events, error: error) if delivery_mode?
99
-
100
- execute(<<~SQL)
101
- UPDATE #{quoted_table}
102
- SET attempts = attempts + 1,
103
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
104
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
105
- locked_at = NULL,
106
- locked_by = NULL,
107
- last_error = #{quote(truncate_error(error))},
108
- updated_at = CURRENT_TIMESTAMP
109
- WHERE id IN (#{ids_sql(ids)})
110
- SQL
111
- ids
97
+ acknowledge!(events) do
98
+ <<~SQL.chomp
99
+ attempts = attempts + 1,
100
+ status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
101
+ next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
102
+ last_error = #{quote(truncate_error(error))}
103
+ SQL
104
+ end
112
105
  end
113
106
 
114
- # @param events [Array<SearchEngine::PostgresOutbox::Event, Integer, String>]
107
+ # @param events [Array<SearchEngine::PostgresOutbox::Event>]
115
108
  # @param error [Exception, String]
116
109
  # @return [Array<Integer, String>] event ids whose current lease was acknowledged
117
110
  def mark_failed!(events, error:)
118
- ids = event_ids(events)
119
- return [] if ids.empty?
120
- return mark_delivery_failed!(events, error: error) if delivery_mode?
121
-
122
- execute(<<~SQL)
123
- UPDATE #{quoted_table}
124
- SET status = 'failed',
125
- locked_at = NULL,
126
- locked_by = NULL,
127
- last_error = #{quote(truncate_error(error))},
128
- updated_at = CURRENT_TIMESTAMP
129
- WHERE id IN (#{ids_sql(ids)})
130
- SQL
131
- ids
111
+ acknowledge!(events) { "status = 'failed', last_error = #{quote(truncate_error(error))}" }
132
112
  end
133
113
 
134
114
  # Refresh the lock timestamp only for deliveries still owned by these claims.
@@ -885,7 +865,7 @@ module SearchEngine
885
865
  ORDER BY ranked_candidate_events.id ASC
886
866
  LIMIT #{limit}
887
867
  )
888
- SELECT outbox.*
868
+ SELECT outbox.id, outbox.collection, outbox.document_id
889
869
  FROM #{quoted_table} outbox
890
870
  INNER JOIN latest_due
891
871
  ON latest_due.id = outbox.id
@@ -903,6 +883,7 @@ module SearchEngine
903
883
  updated_at = CURRENT_TIMESTAMP
904
884
  WHERE id IN (#{ids_sql(ids)})
905
885
  AND status = 'pending'
886
+ RETURNING *, locked_by AS event_lease_owner
906
887
  SQL
907
888
  end
908
889
 
@@ -1098,58 +1079,29 @@ module SearchEngine
1098
1079
  refresh_event_statuses!(parent_ids)
1099
1080
  end
1100
1081
 
1101
- def update_status!(events, status, extra:)
1102
- ids = event_ids(events)
1103
- return [] if ids.empty?
1082
+ def acknowledge!(events)
1083
+ events = Array(events).compact
1084
+ return [] if events.empty?
1104
1085
 
1105
- execute(<<~SQL)
1106
- UPDATE #{quoted_table}
1107
- SET status = #{quote(status)},
1108
- #{extra},
1109
- locked_at = NULL,
1110
- locked_by = NULL,
1111
- updated_at = CURRENT_TIMESTAMP
1112
- WHERE id IN (#{ids_sql(ids)})
1113
- SQL
1114
- ids
1115
- end
1086
+ assignments = "#{yield}, locked_at = NULL, locked_by = NULL"
1087
+ return mutate_claimed_deliveries!(events, assignments: assignments) if delivery_mode?
1116
1088
 
1117
- def update_delivery_status!(events, status, extra:)
1118
- mutate_claimed_deliveries!(
1119
- events,
1120
- assignments: <<~SQL.chomp
1121
- status = #{quote(status)},
1122
- #{extra},
1123
- locked_at = NULL,
1124
- locked_by = NULL
1125
- SQL
1126
- )
1127
- end
1089
+ claims = events.map do |event|
1090
+ unless event.is_a?(Event) && event.id && event.delivery_id.nil? && !event.event_lease_owner.to_s.empty?
1091
+ raise ArgumentError, 'single-target mutations require Event objects returned by claim_pending'
1092
+ end
1128
1093
 
1129
- def mark_delivery_retryable!(events, error:)
1130
- mutate_claimed_deliveries!(
1131
- events,
1132
- assignments: <<~SQL.chomp
1133
- attempts = attempts + 1,
1134
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
1135
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
1136
- locked_at = NULL,
1137
- locked_by = NULL,
1138
- last_error = #{quote(truncate_error(error))}
1139
- SQL
1140
- )
1141
- end
1094
+ "(CAST(#{quote(event.id)} AS bigint), #{quote(event.event_lease_owner)})"
1095
+ end
1142
1096
 
1143
- def mark_delivery_failed!(events, error:)
1144
- mutate_claimed_deliveries!(
1145
- events,
1146
- assignments: <<~SQL.chomp
1147
- status = 'failed',
1148
- locked_at = NULL,
1149
- locked_by = NULL,
1150
- last_error = #{quote(truncate_error(error))}
1151
- SQL
1152
- )
1097
+ rows = select_rows(<<~SQL)
1098
+ UPDATE #{quoted_table}
1099
+ SET #{assignments}, updated_at = CURRENT_TIMESTAMP
1100
+ WHERE status = 'processing'
1101
+ AND (id, locked_by) IN (VALUES #{claims.join(', ')})
1102
+ RETURNING id
1103
+ SQL
1104
+ rows.map { |row| row_value(row, :id) }
1153
1105
  end
1154
1106
 
1155
1107
  def mutate_claimed_deliveries!(events, assignments:, refresh_parent: true)
@@ -1308,12 +1260,6 @@ module SearchEngine
1308
1260
  ids.map { |id| quote(id) }.join(', ')
1309
1261
  end
1310
1262
 
1311
- def event_ids(events)
1312
- Array(events).compact.map do |event|
1313
- event.respond_to?(:id) ? event.id : event
1314
- end
1315
- end
1316
-
1317
1263
  def delivery_claims(events)
1318
1264
  Array(events).compact.map do |event|
1319
1265
  unless event.respond_to?(:delivery_id) && event.respond_to?(:delivery_lease_owner)
@@ -1600,7 +1546,7 @@ module SearchEngine
1600
1546
  ORDER BY id ASC
1601
1547
  LIMIT #{collection_limited_candidate_limit}
1602
1548
  )
1603
- SELECT outbox.*
1549
+ SELECT outbox.id, outbox.collection, outbox.document_id
1604
1550
  FROM #{quoted_table} outbox
1605
1551
  INNER JOIN selected_due
1606
1552
  ON selected_due.id = outbox.id
@@ -15,5 +15,6 @@ require 'search_engine/postgres_outbox/migration_helpers'
15
15
  module SearchEngine
16
16
  # PostgreSQL-backed outbox integration points.
17
17
  module PostgresOutbox
18
+ autoload :DrainJob, 'search_engine/postgres_outbox/drain_job'
18
19
  end
19
20
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/inflector'
4
+
3
5
  # Internal SearchEngine registry and APIs for model mapping.
4
6
  module SearchEngine
5
7
  # Internal registry for mapping Typesense collection names to model classes.
@@ -73,40 +75,22 @@ module SearchEngine
73
75
  # Resolve the model class for a given Typesense collection name.
74
76
  #
75
77
  # @param name [#to_s]
76
- # @return [Class]
77
- # @raise [ArgumentError] when the collection is not registered
78
+ # @yield when no model is registered after autoload
79
+ # @return [Class, Object] registered model or the value of the supplied block
80
+ # @raise [ArgumentError] when the collection is not registered and no block is supplied
78
81
  def collection_for(name)
79
82
  normalized_name = name.to_s
80
83
  klass = Registry.mapping[normalized_name]
81
- return klass if klass
82
-
83
- # Lazy autoload fallback: try to resolve a namespaced model constant
84
- # based on the collection name and trigger its registration.
85
- begin
86
- begin
87
- require 'active_support/inflector'
88
- rescue StandardError
89
- nil
90
- end
91
-
92
- demod = if defined?(ActiveSupport::Inflector)
93
- ActiveSupport::Inflector.classify(normalized_name)
94
- else
95
- # Minimal classify fallback: singularize by dropping trailing 's' and camelize tokens
96
- base = normalized_name.end_with?('s') ? normalized_name[0..-2] : normalized_name
97
- base.split('_').map { |p| p[0] ? p[0].upcase + p[1..] : '' }.join
98
- end
99
-
100
- const_name = "SearchEngine::#{demod}"
101
- # Trigger autoload; ignore when constant is missing
102
- Object.const_get(const_name)
103
- rescue NameError
104
- # constant not found; proceed to error
84
+ if klass
85
+ # A class can call `collection` before the rest of its file has loaded.
86
+ # Let Ruby finish (or retry) its autoload before using its identity strategy.
87
+ load_model_constant(Object, klass.name) if klass.name
88
+ else
89
+ load_model_constant(self, ActiveSupport::Inflector.classify(normalized_name))
105
90
  end
106
-
107
- # Re-check after potential autoload/registration
108
91
  klass = Registry.mapping[normalized_name]
109
92
  return klass if klass
93
+ return yield if block_given?
110
94
 
111
95
  message = 'Unregistered collection: ' \
112
96
  "'#{normalized_name}'. " \
@@ -117,6 +101,21 @@ module SearchEngine
117
101
 
118
102
  private
119
103
 
104
+ def load_model_constant(namespace, name)
105
+ name.split('::').each do |part|
106
+ break unless namespace.is_a?(Module) && collection_constant_defined?(namespace, part)
107
+
108
+ namespace = namespace.const_get(part, false)
109
+ end
110
+ end
111
+
112
+ def collection_constant_defined?(namespace, part)
113
+ namespace.const_defined?(part, false)
114
+ rescue NameError
115
+ # Physical collection names can contain characters that cannot appear in Ruby constants.
116
+ false
117
+ end
118
+
120
119
  def mapping_idempotent?(current, new_klass)
121
120
  return false unless current
122
121
 
@@ -91,13 +91,13 @@ module SearchEngine
91
91
  compiled = SearchEngine::Compiler.compile(ast_nodes, klass: @klass)
92
92
  return nil if compiled.to_s.empty?
93
93
 
94
- return compiled
94
+ return map_join_tokens(compiled)
95
95
  end
96
96
 
97
97
  fragments = Array(@state[:filters])
98
98
  return nil if fragments.empty?
99
99
 
100
- fragments.join(' && ')
100
+ map_join_tokens(fragments.join(' && '))
101
101
  end
102
102
 
103
103
  # Compile sort_by from normalized order entries.
@@ -276,16 +276,10 @@ module SearchEngine
276
276
  end
277
277
 
278
278
  def add_pagination_line!(lines, params)
279
- page = params[:page]
280
- per = params[:per_page]
281
- return unless page || per
282
-
283
- if page && per
284
- lines << " page/per: #{page}/#{per}"
285
- elsif page
286
- lines << " page/per: #{page}/"
287
- elsif per
288
- lines << " page/per: /#{per}"
279
+ if params.key?(:offset)
280
+ lines << " offset/limit: #{params[:offset]}/#{params[:limit]}"
281
+ elsif params.key?(:page) || params.key?(:per_page)
282
+ lines << " page/per: #{params[:page]}/#{params[:per_page]}"
289
283
  end
290
284
  end
291
285
 
@@ -384,7 +378,6 @@ module SearchEngine
384
378
 
385
379
  def assign_filter_by!(params, ast_nodes)
386
380
  filter_str = compiled_filter_by(ast_nodes)
387
- filter_str = map_join_tokens(filter_str)
388
381
  params[:filter_by] = filter_str if filter_str
389
382
  filter_str
390
383
  end
@@ -417,17 +410,12 @@ module SearchEngine
417
410
  rewrites = join_token_rewrites
418
411
  return value if rewrites.empty?
419
412
 
420
- mapped = str.dup
421
- rewrites.each do |assoc, collection|
422
- coll = collection.to_s
423
- assoc_token = assoc.to_s
424
- next if coll.strip.empty? || assoc_token.empty? || coll == assoc_token
425
-
426
- pattern = /\$#{Regexp.escape(assoc_token)}(?=[.(])/
427
- mapped = mapped.gsub(pattern, "$#{coll}")
413
+ names = Regexp.union(rewrites.keys.map(&:to_s))
414
+ # Backtick-delimited values can contain literal reference syntax.
415
+ str.gsub(/`[^`]*`|\$(#{names})(?=[.(])/) do |match|
416
+ name = Regexp.last_match(1)
417
+ name ? "$#{rewrites.fetch(name.to_sym)}" : match
428
418
  end
429
-
430
- mapped
431
419
  end
432
420
 
433
421
  def join_token_rewrites
@@ -687,17 +675,14 @@ module SearchEngine
687
675
  def apply_pagination_and_hit_limits!(params)
688
676
  hits_info = {}
689
677
  pagination = compute_pagination
690
- if (hl = @state[:hit_limits]) && hl[:early_limit]
691
- if pagination.key?(:per_page) && pagination[:per_page].to_i > hl[:early_limit].to_i
692
- pagination = pagination.merge(per_page: hl[:early_limit].to_i)
693
- hits_info[:per_adjusted] = true
694
- else
695
- hits_info[:per_adjusted] = false
696
- end
697
- hits_info[:early_limit] = hl[:early_limit].to_i
678
+ if (early_limit = @state.dig(:hit_limits, :early_limit))
679
+ size_key = pagination.key?(:limit) ? :limit : :per_page
680
+ size = pagination[size_key]
681
+ hits_info[:per_adjusted] = size.to_i > early_limit
682
+ pagination[size_key] = early_limit if hits_info[:per_adjusted]
683
+ hits_info[:early_limit] = early_limit
698
684
  end
699
- params[:page] = pagination[:page] if pagination.key?(:page)
700
- params[:per_page] = pagination[:per_page] if pagination.key?(:per_page)
685
+ params.merge!(pagination)
701
686
  hits_info
702
687
  end
703
688