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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +47 -13
- data/lib/search_engine/active_record_syncable.rb +85 -197
- data/lib/search_engine/ast/node.rb +3 -6
- data/lib/search_engine/async_partition_coordinator.rb +5 -3
- data/lib/search_engine/client/services/search.rb +2 -0
- data/lib/search_engine/client.rb +9 -3
- data/lib/search_engine/compiler.rb +33 -128
- data/lib/search_engine/deletion.rb +12 -9
- data/lib/search_engine/dsl/parser.rb +51 -71
- data/lib/search_engine/engine.rb +25 -16
- data/lib/search_engine/filters/sanitizer.rb +162 -126
- data/lib/search_engine/hydration/materializers.rb +53 -118
- data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
- data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
- data/lib/search_engine/indexing_run_store.rb +10 -0
- data/lib/search_engine/joins/guard.rb +14 -8
- data/lib/search_engine/multi.rb +92 -6
- data/lib/search_engine/multi_result.rb +40 -23
- data/lib/search_engine/notifications/compact_logger.rb +2 -2
- data/lib/search_engine/observability.rb +6 -6
- data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
- data/lib/search_engine/postgres_outbox/event.rb +12 -0
- data/lib/search_engine/postgres_outbox/repository.rb +48 -102
- data/lib/search_engine/postgres_outbox.rb +1 -0
- data/lib/search_engine/registry.rb +27 -28
- data/lib/search_engine/relation/compiler.rb +18 -33
- data/lib/search_engine/relation/dsl/filters.rb +71 -175
- data/lib/search_engine/relation/dx.rb +3 -1
- data/lib/search_engine/relation/materializers.rb +6 -3
- data/lib/search_engine/relation/options.rb +5 -26
- data/lib/search_engine/relation/state.rb +3 -2
- data/lib/search_engine/relation.rb +5 -4
- data/lib/search_engine/result.rb +33 -2
- data/lib/search_engine/schema.rb +23 -24
- data/lib/search_engine/test/offline_client.rb +2 -2
- data/lib/search_engine/test/stub_client.rb +9 -16
- data/lib/search_engine/update.rb +1 -1
- data/lib/search_engine/version.rb +1 -1
- data/lib/search_engine.rb +11 -149
- data/lib/tasks/search_engine.rake +3 -5
- metadata +17 -3
- /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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1102
|
-
|
|
1103
|
-
return [] if
|
|
1082
|
+
def acknowledge!(events)
|
|
1083
|
+
events = Array(events).compact
|
|
1084
|
+
return [] if events.empty?
|
|
1104
1085
|
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
|
@@ -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
|
-
# @
|
|
77
|
-
# @
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 (
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
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
|
|