search-engine-for-typesense 30.9.1 → 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 +19 -0
- data/README.md +51 -16
- 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/database_queries.rb +17 -0
- 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/materialization_cursor.rb +25 -11
- data/lib/search_engine/postgres_outbox/repository.rb +65 -115
- data/lib/search_engine/postgres_outbox.rb +2 -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 +18 -3
- /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
|
@@ -5,11 +5,14 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
module SearchEngine
|
|
7
7
|
module PostgresOutbox
|
|
8
|
-
#
|
|
9
|
-
# the other sweeps backwards to revisit late commits, due retries and existing events.
|
|
8
|
+
# Scan under the materialization lock. Keep moving through history even when new IDs arrive faster.
|
|
10
9
|
class MaterializationCursor
|
|
10
|
+
include DatabaseQueries
|
|
11
|
+
|
|
11
12
|
PAGE_SIZE = 1000
|
|
12
13
|
|
|
14
|
+
attr_reader :checkpoint_ids
|
|
15
|
+
|
|
13
16
|
def initialize(connection, events_table, cursor_table, target_keys)
|
|
14
17
|
@connection = connection
|
|
15
18
|
@events = events_table
|
|
@@ -21,7 +24,7 @@ module SearchEngine
|
|
|
21
24
|
# @return [Array<Integer>] event IDs to check against the configured delivery targets
|
|
22
25
|
def candidate_ids(limit:)
|
|
23
26
|
@page_size = [PAGE_SIZE, limit].min
|
|
24
|
-
state =
|
|
27
|
+
state = select_rows(<<~SQL).first
|
|
25
28
|
SELECT forward_id, backfill_through_id FROM #{@table} WHERE target_scope = #{@scope}
|
|
26
29
|
SQL
|
|
27
30
|
if state
|
|
@@ -34,18 +37,27 @@ module SearchEngine
|
|
|
34
37
|
# Recheck a recent ID range for concurrent inserts that commit behind the watermark.
|
|
35
38
|
# Even for dense IDs, at least half of the page remains available for new events.
|
|
36
39
|
@forward_page = event_ids("id > #{@forward_id - @page_size / 2} ORDER BY id ASC")
|
|
37
|
-
|
|
38
|
-
@forward_page
|
|
40
|
+
# A returning target can be far behind. Reserve some of the second page for the current head.
|
|
41
|
+
@recent_page = if @forward_page.size == @page_size
|
|
42
|
+
event_ids("id > #{@forward_page.last} ORDER BY id DESC", [@page_size / 2, 1].max)
|
|
43
|
+
else
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
@backfill_limit = [@page_size - @recent_page.size, 1].max
|
|
47
|
+
@backfill_page = event_ids("id <= #{@backfill_through_id} ORDER BY id DESC", @backfill_limit)
|
|
48
|
+
@checkpoint_ids = @forward_page | @backfill_page
|
|
49
|
+
@checkpoint_ids | @recent_page
|
|
39
50
|
end
|
|
40
51
|
|
|
41
52
|
# Save progress in the same transaction as the new deliveries.
|
|
42
53
|
def advance!
|
|
43
54
|
forward = [@forward_page.last || @forward_id, @forward_id].max
|
|
44
55
|
# Freeze the upper boundary until the backwards sweep reaches the beginning.
|
|
45
|
-
|
|
56
|
+
head = [forward, @recent_page.first || forward].max
|
|
57
|
+
backfill = @backfill_page.size < @backfill_limit ? head : @backfill_page.last - 1
|
|
46
58
|
return if forward == @forward_id && backfill == @backfill_through_id
|
|
47
59
|
|
|
48
|
-
|
|
60
|
+
connection.execute(<<~SQL)
|
|
49
61
|
UPDATE #{@table} SET forward_id = #{forward}, backfill_through_id = #{backfill}
|
|
50
62
|
WHERE target_scope = #{@scope}
|
|
51
63
|
SQL
|
|
@@ -53,17 +65,19 @@ module SearchEngine
|
|
|
53
65
|
|
|
54
66
|
private
|
|
55
67
|
|
|
68
|
+
attr_reader :connection
|
|
69
|
+
|
|
56
70
|
def initialize_positions
|
|
57
|
-
head = Integer(
|
|
58
|
-
|
|
71
|
+
head = Integer(select_rows("SELECT COALESCE(MAX(id), 0) AS head FROM #{@events}").first.fetch('head'))
|
|
72
|
+
connection.execute(<<~SQL)
|
|
59
73
|
INSERT INTO #{@table} (target_scope, forward_id, backfill_through_id)
|
|
60
74
|
VALUES (#{@scope}, #{head}, #{head})
|
|
61
75
|
SQL
|
|
62
76
|
[head, head]
|
|
63
77
|
end
|
|
64
78
|
|
|
65
|
-
def event_ids(order)
|
|
66
|
-
|
|
79
|
+
def event_ids(order, limit = @page_size)
|
|
80
|
+
select_rows("SELECT id FROM #{@events} WHERE #{order} LIMIT #{limit}").map do |row|
|
|
67
81
|
Integer(row.fetch('id'))
|
|
68
82
|
end
|
|
69
83
|
end
|
|
@@ -7,6 +7,8 @@ module SearchEngine
|
|
|
7
7
|
module PostgresOutbox
|
|
8
8
|
# Raw SQL repository for host-managed PostgreSQL outbox rows.
|
|
9
9
|
class Repository
|
|
10
|
+
include DatabaseQueries
|
|
11
|
+
|
|
10
12
|
# Keep expensive duplicate-key ranking bounded while leaving headroom for hot keys.
|
|
11
13
|
CLAIM_CANDIDATE_OVERSAMPLE = 4
|
|
12
14
|
MATERIALIZATION_LOCK_NAMESPACE = 0x53454F42 # SEOB
|
|
@@ -28,6 +30,12 @@ module SearchEngine
|
|
|
28
30
|
@monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
29
31
|
end
|
|
30
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
|
+
|
|
31
39
|
# Claim pending rows for one worker and return event objects.
|
|
32
40
|
# @param limit [Integer]
|
|
33
41
|
# @param worker_id [String]
|
|
@@ -36,16 +44,15 @@ module SearchEngine
|
|
|
36
44
|
return claim_pending_deliveries(limit: limit, worker_id: worker_id) if delivery_mode?
|
|
37
45
|
|
|
38
46
|
reset_stale_processing!
|
|
39
|
-
rows = []
|
|
40
|
-
|
|
41
47
|
connection.transaction do
|
|
42
48
|
rows = select_rows(claim_select_sql(limit))
|
|
49
|
+
next [] if rows.empty?
|
|
50
|
+
|
|
51
|
+
execute(supersede_older_pending_sql(rows))
|
|
43
52
|
ids = rows.map { |row| row_value(row, :id) }
|
|
44
|
-
|
|
45
|
-
|
|
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 }
|
|
46
55
|
end
|
|
47
|
-
|
|
48
|
-
rows.map { |row| Event.new(row) }
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
# Reset timed-out processing rows to pending.
|
|
@@ -71,62 +78,37 @@ module SearchEngine
|
|
|
71
78
|
SQL
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
# @param events [Array<SearchEngine::PostgresOutbox::Event
|
|
81
|
+
# @param events [Array<SearchEngine::PostgresOutbox::Event>]
|
|
75
82
|
# @return [Array<Integer, String>] event ids whose current lease was acknowledged
|
|
76
83
|
def mark_processed!(events)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
update_status!(events, 'processed', extra: PROCESSED_AT_ASSIGNMENT)
|
|
84
|
+
acknowledge!(events) { "status = 'processed', #{PROCESSED_AT_ASSIGNMENT}" }
|
|
80
85
|
end
|
|
81
86
|
|
|
82
|
-
# @param events [Array<SearchEngine::PostgresOutbox::Event
|
|
87
|
+
# @param events [Array<SearchEngine::PostgresOutbox::Event>]
|
|
83
88
|
# @return [Array<Integer, String>] event ids whose current lease was acknowledged
|
|
84
89
|
def mark_superseded!(events)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
update_status!(events, 'superseded', extra: PROCESSED_AT_ASSIGNMENT)
|
|
90
|
+
acknowledge!(events) { "status = 'superseded', #{PROCESSED_AT_ASSIGNMENT}" }
|
|
88
91
|
end
|
|
89
92
|
|
|
90
|
-
# @param events [Array<SearchEngine::PostgresOutbox::Event
|
|
93
|
+
# @param events [Array<SearchEngine::PostgresOutbox::Event>]
|
|
91
94
|
# @param error [Exception, String]
|
|
92
95
|
# @return [Array<Integer, String>] event ids whose current lease was acknowledged
|
|
93
96
|
def mark_retryable!(events, error:)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
|
|
103
|
-
locked_at = NULL,
|
|
104
|
-
locked_by = NULL,
|
|
105
|
-
last_error = #{quote(truncate_error(error))},
|
|
106
|
-
updated_at = CURRENT_TIMESTAMP
|
|
107
|
-
WHERE id IN (#{ids_sql(ids)})
|
|
108
|
-
SQL
|
|
109
|
-
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
|
|
110
105
|
end
|
|
111
106
|
|
|
112
|
-
# @param events [Array<SearchEngine::PostgresOutbox::Event
|
|
107
|
+
# @param events [Array<SearchEngine::PostgresOutbox::Event>]
|
|
113
108
|
# @param error [Exception, String]
|
|
114
109
|
# @return [Array<Integer, String>] event ids whose current lease was acknowledged
|
|
115
110
|
def mark_failed!(events, error:)
|
|
116
|
-
|
|
117
|
-
return [] if ids.empty?
|
|
118
|
-
return mark_delivery_failed!(events, error: error) if delivery_mode?
|
|
119
|
-
|
|
120
|
-
execute(<<~SQL)
|
|
121
|
-
UPDATE #{quoted_table}
|
|
122
|
-
SET status = 'failed',
|
|
123
|
-
locked_at = NULL,
|
|
124
|
-
locked_by = NULL,
|
|
125
|
-
last_error = #{quote(truncate_error(error))},
|
|
126
|
-
updated_at = CURRENT_TIMESTAMP
|
|
127
|
-
WHERE id IN (#{ids_sql(ids)})
|
|
128
|
-
SQL
|
|
129
|
-
ids
|
|
111
|
+
acknowledge!(events) { "status = 'failed', last_error = #{quote(truncate_error(error))}" }
|
|
130
112
|
end
|
|
131
113
|
|
|
132
114
|
# Refresh the lock timestamp only for deliveries still owned by these claims.
|
|
@@ -198,7 +180,7 @@ module SearchEngine
|
|
|
198
180
|
event_ids = cursor&.candidate_ids(limit: scan_limit)
|
|
199
181
|
preview = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: event_ids, lock: false))
|
|
200
182
|
rows = preview.empty? ? [] : materialize_batch(limit, targets, preview)
|
|
201
|
-
if cursor && rows.size == preview.size &&
|
|
183
|
+
if cursor && rows.size == preview.size && page_drained?(rows, event_ids, limit, cursor, targets)
|
|
202
184
|
cursor.advance!
|
|
203
185
|
end
|
|
204
186
|
rows
|
|
@@ -414,14 +396,23 @@ module SearchEngine
|
|
|
414
396
|
end
|
|
415
397
|
|
|
416
398
|
# When a quota may have omitted matches, revisit the page after these deliveries commit.
|
|
417
|
-
def
|
|
399
|
+
def page_drained?(rows, event_ids, limit, cursor, targets)
|
|
418
400
|
return true if rows.empty? || rows.size == event_ids.size
|
|
419
401
|
|
|
420
402
|
batch_size = global_limit_for(limit)
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
403
|
+
if collection_limited_batch?(limit)
|
|
404
|
+
quotas = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
|
|
405
|
+
batch_size = [batch_size, *quotas].min
|
|
406
|
+
end
|
|
407
|
+
return true if rows.size < batch_size
|
|
408
|
+
return false if event_ids.size == cursor.checkpoint_ids.size
|
|
409
|
+
|
|
410
|
+
# A moving head can fill a quota on every poll. Only work on the saved pages must hold progress.
|
|
411
|
+
select_rows(<<~SQL).empty?
|
|
412
|
+
SELECT 1 FROM #{quoted_table} outbox
|
|
413
|
+
WHERE #{materialization_filter_sql(targets, cursor.checkpoint_ids)}
|
|
414
|
+
LIMIT 1
|
|
415
|
+
SQL
|
|
425
416
|
end
|
|
426
417
|
|
|
427
418
|
# Revalidate the exact preview under parent locks before locking any deliveries.
|
|
@@ -874,7 +865,7 @@ module SearchEngine
|
|
|
874
865
|
ORDER BY ranked_candidate_events.id ASC
|
|
875
866
|
LIMIT #{limit}
|
|
876
867
|
)
|
|
877
|
-
SELECT outbox
|
|
868
|
+
SELECT outbox.id, outbox.collection, outbox.document_id
|
|
878
869
|
FROM #{quoted_table} outbox
|
|
879
870
|
INNER JOIN latest_due
|
|
880
871
|
ON latest_due.id = outbox.id
|
|
@@ -892,6 +883,7 @@ module SearchEngine
|
|
|
892
883
|
updated_at = CURRENT_TIMESTAMP
|
|
893
884
|
WHERE id IN (#{ids_sql(ids)})
|
|
894
885
|
AND status = 'pending'
|
|
886
|
+
RETURNING *, locked_by AS event_lease_owner
|
|
895
887
|
SQL
|
|
896
888
|
end
|
|
897
889
|
|
|
@@ -1087,58 +1079,29 @@ module SearchEngine
|
|
|
1087
1079
|
refresh_event_statuses!(parent_ids)
|
|
1088
1080
|
end
|
|
1089
1081
|
|
|
1090
|
-
def
|
|
1091
|
-
|
|
1092
|
-
return [] if
|
|
1082
|
+
def acknowledge!(events)
|
|
1083
|
+
events = Array(events).compact
|
|
1084
|
+
return [] if events.empty?
|
|
1093
1085
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
SET status = #{quote(status)},
|
|
1097
|
-
#{extra},
|
|
1098
|
-
locked_at = NULL,
|
|
1099
|
-
locked_by = NULL,
|
|
1100
|
-
updated_at = CURRENT_TIMESTAMP
|
|
1101
|
-
WHERE id IN (#{ids_sql(ids)})
|
|
1102
|
-
SQL
|
|
1103
|
-
ids
|
|
1104
|
-
end
|
|
1086
|
+
assignments = "#{yield}, locked_at = NULL, locked_by = NULL"
|
|
1087
|
+
return mutate_claimed_deliveries!(events, assignments: assignments) if delivery_mode?
|
|
1105
1088
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
status = #{quote(status)},
|
|
1111
|
-
#{extra},
|
|
1112
|
-
locked_at = NULL,
|
|
1113
|
-
locked_by = NULL
|
|
1114
|
-
SQL
|
|
1115
|
-
)
|
|
1116
|
-
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
|
|
1117
1093
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
events,
|
|
1121
|
-
assignments: <<~SQL.chomp
|
|
1122
|
-
attempts = attempts + 1,
|
|
1123
|
-
status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
|
|
1124
|
-
next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
|
|
1125
|
-
locked_at = NULL,
|
|
1126
|
-
locked_by = NULL,
|
|
1127
|
-
last_error = #{quote(truncate_error(error))}
|
|
1128
|
-
SQL
|
|
1129
|
-
)
|
|
1130
|
-
end
|
|
1094
|
+
"(CAST(#{quote(event.id)} AS bigint), #{quote(event.event_lease_owner)})"
|
|
1095
|
+
end
|
|
1131
1096
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
SQL
|
|
1141
|
-
)
|
|
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) }
|
|
1142
1105
|
end
|
|
1143
1106
|
|
|
1144
1107
|
def mutate_claimed_deliveries!(events, assignments:, refresh_parent: true)
|
|
@@ -1289,13 +1252,6 @@ module SearchEngine
|
|
|
1289
1252
|
'THEN COALESCE(aggregate.terminal_processed_at, CURRENT_TIMESTAMP) ELSE NULL END'
|
|
1290
1253
|
end
|
|
1291
1254
|
|
|
1292
|
-
def select_rows(sql)
|
|
1293
|
-
result = connection.select_all(sql)
|
|
1294
|
-
return result.to_a if result.respond_to?(:to_a)
|
|
1295
|
-
|
|
1296
|
-
Array(result)
|
|
1297
|
-
end
|
|
1298
|
-
|
|
1299
1255
|
def execute(sql)
|
|
1300
1256
|
connection.execute(sql)
|
|
1301
1257
|
end
|
|
@@ -1304,12 +1260,6 @@ module SearchEngine
|
|
|
1304
1260
|
ids.map { |id| quote(id) }.join(', ')
|
|
1305
1261
|
end
|
|
1306
1262
|
|
|
1307
|
-
def event_ids(events)
|
|
1308
|
-
Array(events).compact.map do |event|
|
|
1309
|
-
event.respond_to?(:id) ? event.id : event
|
|
1310
|
-
end
|
|
1311
|
-
end
|
|
1312
|
-
|
|
1313
1263
|
def delivery_claims(events)
|
|
1314
1264
|
Array(events).compact.map do |event|
|
|
1315
1265
|
unless event.respond_to?(:delivery_id) && event.respond_to?(:delivery_lease_owner)
|
|
@@ -1596,7 +1546,7 @@ module SearchEngine
|
|
|
1596
1546
|
ORDER BY id ASC
|
|
1597
1547
|
LIMIT #{collection_limited_candidate_limit}
|
|
1598
1548
|
)
|
|
1599
|
-
SELECT outbox
|
|
1549
|
+
SELECT outbox.id, outbox.collection, outbox.document_id
|
|
1600
1550
|
FROM #{quoted_table} outbox
|
|
1601
1551
|
INNER JOIN selected_due
|
|
1602
1552
|
ON selected_due.id = outbox.id
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'search_engine/postgres_outbox/event'
|
|
4
4
|
require 'search_engine/postgres_outbox/delivery_target'
|
|
5
5
|
require 'search_engine/postgres_outbox/processor_result'
|
|
6
|
+
require 'search_engine/postgres_outbox/database_queries'
|
|
6
7
|
require 'search_engine/postgres_outbox/materialization_cursor'
|
|
7
8
|
require 'search_engine/postgres_outbox/repository'
|
|
8
9
|
require 'search_engine/postgres_outbox/event_processor'
|
|
@@ -14,5 +15,6 @@ require 'search_engine/postgres_outbox/migration_helpers'
|
|
|
14
15
|
module SearchEngine
|
|
15
16
|
# PostgreSQL-backed outbox integration points.
|
|
16
17
|
module PostgresOutbox
|
|
18
|
+
autoload :DrainJob, 'search_engine/postgres_outbox/drain_job'
|
|
17
19
|
end
|
|
18
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
|
-
# @
|
|
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
|
|