search-engine-for-typesense 30.9.0 → 30.9.2
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 +13 -0
- data/README.md +17 -0
- data/lib/search_engine/config.rb +3 -0
- data/lib/search_engine/postgres_outbox/database_queries.rb +17 -0
- data/lib/search_engine/postgres_outbox/materialization_cursor.rb +86 -0
- data/lib/search_engine/postgres_outbox/migration_helpers.rb +9 -0
- data/lib/search_engine/postgres_outbox/repository.rb +105 -132
- data/lib/search_engine/postgres_outbox.rb +2 -0
- data/lib/search_engine/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2a45aebc5484036b192915f9de295af3efbce20c7831c3e301dd9608b68a26d
|
|
4
|
+
data.tar.gz: e7274a561a6f852f5daa8489f33349bbdf430da811f8e0db3556c36547a1098e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38f920eb23837108385ad80e09607d6e8f221847660af0870de49552110305d5b1a8d8a4186d0f9635b609ccafdaf0817127fa911cf5f3a9f9d17f1d6ea3d032
|
|
7
|
+
data.tar.gz: c89b82cbb8575cc4608bbeab0536599aa0b5d37f26198117e6f519df40fd0b588919297c7fa26b0466efaa370fce9df56cd196e9a21cfcb15a7cebd679bd01a8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 30.9.2
|
|
4
|
+
|
|
5
|
+
- Check recent event IDs during catch-up so a returning delivery target can discover new work
|
|
6
|
+
while its saved position moves through older events. Collection quotas still apply.
|
|
7
|
+
- Read outbox checkpoints, delivery state and row locks directly from the database on every poll,
|
|
8
|
+
including when Active Record query caching is enabled on Rails 6.1 and 7.0.
|
|
9
|
+
|
|
10
|
+
## 30.9.1
|
|
11
|
+
|
|
12
|
+
- Serialize PostgreSQL delivery discovery across schedulers and workers and check delivery membership
|
|
13
|
+
by event and target. Hosts can enable checkpointed scans to bound discovery on large outboxes.
|
|
14
|
+
- Execute advisory locks and delivery cutover polls on the database when Rails query caching is enabled.
|
|
15
|
+
|
|
3
16
|
## 30.9.0
|
|
4
17
|
|
|
5
18
|
- Qualify geo/vector search and native caching against Typesense 30.2 while keeping the gem major
|
data/README.md
CHANGED
|
@@ -565,6 +565,23 @@ target-specific status, retry, lock, and queue state for each logical event. Add
|
|
|
565
565
|
`create_search_engine_outbox_drain_slots` in that delivery-target migration so the drain enqueuer can cap
|
|
566
566
|
queued and running drain jobs per target.
|
|
567
567
|
|
|
568
|
+
Concurrent listener, scheduler and worker calls share one nonblocking PostgreSQL transaction lock for
|
|
569
|
+
delivery discovery on each events table. Other callers can keep claiming existing deliveries.
|
|
570
|
+
For large histories, create a checkpoint table in a migration with
|
|
571
|
+
`create_search_engine_outbox_materialization_cursors`, then set
|
|
572
|
+
`c.postgres_outbox.materialization_cursor_table_name = 'search_engine_outbox_materialization_cursors'`.
|
|
573
|
+
Run the migration before starting consumers with this setting.
|
|
574
|
+
|
|
575
|
+
With checkpoints, each discovery call checks at most 2,000 event IDs. Each target set maintains
|
|
576
|
+
its own forward position and a historical sweep. The forward scan overlaps its previous range
|
|
577
|
+
to catch nearby out-of-order commits. When it falls behind, part of the historical scan budget
|
|
578
|
+
checks the newest IDs while the forward scan continues through the intervening events. A
|
|
579
|
+
transaction that commits farther behind, or an older event that becomes due, can wait until
|
|
580
|
+
the historical scan revisits its ID. Sweep time depends on retained history and polling rate.
|
|
581
|
+
Collection batch quotas and the latest-event rule still govern which deliveries may be created.
|
|
582
|
+
Without a configured checkpoint table the existing full-history discovery uses indexed per-event
|
|
583
|
+
delivery checks and the same concurrency lock.
|
|
584
|
+
|
|
568
585
|
Drain slots are a generic backpressure mechanism for delivery-target mode. On each listener or polling
|
|
569
586
|
wakeup, the enqueuer materializes missing delivery rows, acquires idle slots from
|
|
570
587
|
`c.postgres_outbox.drain_slot_table_name`, and enqueues one drain job per acquired slot. If all slots for a
|
data/lib/search_engine/config.rb
CHANGED
|
@@ -278,6 +278,8 @@ module SearchEngine
|
|
|
278
278
|
attr_accessor :delivery_table_name
|
|
279
279
|
# @return [String] database table used by host-managed drain slots
|
|
280
280
|
attr_accessor :drain_slot_table_name
|
|
281
|
+
# @return [String, nil] optional checkpoint table for bounded delivery discovery
|
|
282
|
+
attr_accessor :materialization_cursor_table_name
|
|
281
283
|
# @return [String] PostgreSQL notification channel for wakeups
|
|
282
284
|
attr_accessor :channel
|
|
283
285
|
# @return [String] queue name used by host app job dispatch
|
|
@@ -936,6 +938,7 @@ module SearchEngine
|
|
|
936
938
|
table_name: postgres_outbox.table_name,
|
|
937
939
|
delivery_table_name: postgres_outbox.delivery_table_name,
|
|
938
940
|
drain_slot_table_name: postgres_outbox.drain_slot_table_name,
|
|
941
|
+
materialization_cursor_table_name: postgres_outbox.materialization_cursor_table_name,
|
|
939
942
|
channel: postgres_outbox.channel,
|
|
940
943
|
queue_name: postgres_outbox.queue_name,
|
|
941
944
|
batch_size: postgres_outbox.batch_size,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
module PostgresOutbox
|
|
5
|
+
# Operational reads must observe current delivery state and acquire row locks on each poll.
|
|
6
|
+
module DatabaseQueries
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def select_rows(sql)
|
|
10
|
+
result = connection.respond_to?(:exec_query) ? connection.exec_query(sql) : connection.select_all(sql)
|
|
11
|
+
return result.to_a if result.respond_to?(:to_a)
|
|
12
|
+
|
|
13
|
+
Array(result)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module SearchEngine
|
|
7
|
+
module PostgresOutbox
|
|
8
|
+
# Scan under the materialization lock. Keep moving through history even when new IDs arrive faster.
|
|
9
|
+
class MaterializationCursor
|
|
10
|
+
include DatabaseQueries
|
|
11
|
+
|
|
12
|
+
PAGE_SIZE = 1000
|
|
13
|
+
|
|
14
|
+
attr_reader :checkpoint_ids
|
|
15
|
+
|
|
16
|
+
def initialize(connection, events_table, cursor_table, target_keys)
|
|
17
|
+
@connection = connection
|
|
18
|
+
@events = events_table
|
|
19
|
+
@table = connection.quote_table_name(cursor_table)
|
|
20
|
+
@scope = connection.quote(Digest::SHA256.hexdigest(JSON.generate([events_table, target_keys.uniq.sort])))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param limit [Integer] upper bound on the number of event IDs to read in each direction
|
|
24
|
+
# @return [Array<Integer>] event IDs to check against the configured delivery targets
|
|
25
|
+
def candidate_ids(limit:)
|
|
26
|
+
@page_size = [PAGE_SIZE, limit].min
|
|
27
|
+
state = select_rows(<<~SQL).first
|
|
28
|
+
SELECT forward_id, backfill_through_id FROM #{@table} WHERE target_scope = #{@scope}
|
|
29
|
+
SQL
|
|
30
|
+
if state
|
|
31
|
+
@forward_id = Integer(state.fetch('forward_id'))
|
|
32
|
+
@backfill_through_id = Integer(state.fetch('backfill_through_id'))
|
|
33
|
+
else
|
|
34
|
+
@forward_id, @backfill_through_id = initialize_positions
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Recheck a recent ID range for concurrent inserts that commit behind the watermark.
|
|
38
|
+
# Even for dense IDs, at least half of the page remains available for new events.
|
|
39
|
+
@forward_page = event_ids("id > #{@forward_id - @page_size / 2} ORDER BY id ASC")
|
|
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
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Save progress in the same transaction as the new deliveries.
|
|
53
|
+
def advance!
|
|
54
|
+
forward = [@forward_page.last || @forward_id, @forward_id].max
|
|
55
|
+
# Freeze the upper boundary until the backwards sweep reaches the beginning.
|
|
56
|
+
head = [forward, @recent_page.first || forward].max
|
|
57
|
+
backfill = @backfill_page.size < @backfill_limit ? head : @backfill_page.last - 1
|
|
58
|
+
return if forward == @forward_id && backfill == @backfill_through_id
|
|
59
|
+
|
|
60
|
+
connection.execute(<<~SQL)
|
|
61
|
+
UPDATE #{@table} SET forward_id = #{forward}, backfill_through_id = #{backfill}
|
|
62
|
+
WHERE target_scope = #{@scope}
|
|
63
|
+
SQL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :connection
|
|
69
|
+
|
|
70
|
+
def initialize_positions
|
|
71
|
+
head = Integer(select_rows("SELECT COALESCE(MAX(id), 0) AS head FROM #{@events}").first.fetch('head'))
|
|
72
|
+
connection.execute(<<~SQL)
|
|
73
|
+
INSERT INTO #{@table} (target_scope, forward_id, backfill_through_id)
|
|
74
|
+
VALUES (#{@scope}, #{head}, #{head})
|
|
75
|
+
SQL
|
|
76
|
+
[head, head]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def event_ids(order, limit = @page_size)
|
|
80
|
+
select_rows("SELECT id FROM #{@events} WHERE #{order} LIMIT #{limit}").map do |row|
|
|
81
|
+
Integer(row.fetch('id'))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -102,6 +102,15 @@ module SearchEngine
|
|
|
102
102
|
on_delete: :cascade
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# Create the small checkpoint table used for bounded delivery discovery.
|
|
106
|
+
# @param table_name [String, Symbol] table configured as materialization_cursor_table_name
|
|
107
|
+
def create_search_engine_outbox_materialization_cursors(table_name: :search_engine_outbox_materialization_cursors)
|
|
108
|
+
create_table table_name, id: :string, primary_key: :target_scope do |t|
|
|
109
|
+
t.bigint :forward_id, null: false
|
|
110
|
+
t.bigint :backfill_through_id, null: false
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
105
114
|
# Create the SearchEngine outbox drain slots table.
|
|
106
115
|
#
|
|
107
116
|
# @param table_name [String, Symbol] destination drain slots table name
|
|
@@ -7,8 +7,11 @@ 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
|
|
14
|
+
MATERIALIZATION_LOCK_NAMESPACE = 0x53454F42 # SEOB
|
|
12
15
|
ERROR_LIMIT = 1000
|
|
13
16
|
PROCESSED_AT_ASSIGNMENT = 'processed_at = CURRENT_TIMESTAMP'
|
|
14
17
|
RETIREMENT_BATCH_SIZE = 1000
|
|
@@ -181,43 +184,27 @@ module SearchEngine
|
|
|
181
184
|
with_delivery_target_session_lock(target, mode: :shared, timing: timing, &block)
|
|
182
185
|
end
|
|
183
186
|
|
|
184
|
-
# Create missing
|
|
185
|
-
# @return [
|
|
187
|
+
# Create missing deliveries. Concurrent pollers leave discovery to the current owner.
|
|
188
|
+
# @return [Array<Hash>, nil] materialized events, or nil when no targets are configured
|
|
186
189
|
def materialize_deliveries!(limit: nil)
|
|
187
190
|
targets = materialization_delivery_targets
|
|
188
191
|
return if targets.empty?
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
preview_rows = select_rows(delivery_materialization_select_sql(limit, targets, lock: false))
|
|
193
|
-
return preview_rows if preview_rows.empty?
|
|
193
|
+
scan_limit = collection_limited_batch?(limit) ? collection_limited_candidate_limit : global_limit_for(limit)
|
|
194
|
+
return [] if scan_limit.zero?
|
|
194
195
|
|
|
195
|
-
supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
|
|
196
|
-
rows = []
|
|
197
196
|
connection.transaction do
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
209
|
-
next if rows.empty?
|
|
210
|
-
|
|
211
|
-
supersede_candidates = select_rows(materialization_supersede_parent_candidates_sql(rows, targets))
|
|
212
|
-
older_event_ids = supersede_candidates.map { |candidate| row_value(candidate, :event_id) }
|
|
213
|
-
older_delivery_ids = supersede_candidates.map { |candidate| row_value(candidate, :delivery_id) }
|
|
214
|
-
lock_delivery_rows!(older_delivery_ids)
|
|
215
|
-
supersede_pending_events_by_id!(older_event_ids)
|
|
216
|
-
supersede_pending_deliveries!(older_delivery_ids, event_ids: older_event_ids)
|
|
217
|
-
execute(delivery_materialization_insert_sql(rows, targets))
|
|
197
|
+
next [] unless materialization_lock_acquired?
|
|
198
|
+
|
|
199
|
+
cursor = materialization_cursor(targets)
|
|
200
|
+
event_ids = cursor&.candidate_ids(limit: scan_limit)
|
|
201
|
+
preview = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: event_ids, lock: false))
|
|
202
|
+
rows = preview.empty? ? [] : materialize_batch(limit, targets, preview)
|
|
203
|
+
if cursor && rows.size == preview.size && page_drained?(rows, event_ids, limit, cursor, targets)
|
|
204
|
+
cursor.advance!
|
|
205
|
+
end
|
|
206
|
+
rows
|
|
218
207
|
end
|
|
219
|
-
|
|
220
|
-
rows
|
|
221
208
|
end
|
|
222
209
|
|
|
223
210
|
# Refresh cleanup-eligible parent event statuses from terminal delivery rows.
|
|
@@ -415,6 +402,58 @@ module SearchEngine
|
|
|
415
402
|
end
|
|
416
403
|
end
|
|
417
404
|
|
|
405
|
+
# PostgreSQL's two-integer lock space is separate from the single-key delivery cutover locks.
|
|
406
|
+
def materialization_lock_acquired?
|
|
407
|
+
sql = <<~SQL
|
|
408
|
+
SELECT pg_try_advisory_xact_lock(#{MATERIALIZATION_LOCK_NAMESPACE}, #{quote(quoted_table)}::regclass::integer)
|
|
409
|
+
SQL
|
|
410
|
+
advisory_lock_truthy?(select_scalar(sql))
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def materialization_cursor(targets)
|
|
414
|
+
table = SearchEngine.config.postgres_outbox.materialization_cursor_table_name
|
|
415
|
+
MaterializationCursor.new(connection, quoted_table, table, targets.map(&:key)) if table
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# When a quota may have omitted matches, revisit the page after these deliveries commit.
|
|
419
|
+
def page_drained?(rows, event_ids, limit, cursor, targets)
|
|
420
|
+
return true if rows.empty? || rows.size == event_ids.size
|
|
421
|
+
|
|
422
|
+
batch_size = global_limit_for(limit)
|
|
423
|
+
if collection_limited_batch?(limit)
|
|
424
|
+
quotas = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
|
|
425
|
+
batch_size = [batch_size, *quotas].min
|
|
426
|
+
end
|
|
427
|
+
return true if rows.size < batch_size
|
|
428
|
+
return false if event_ids.size == cursor.checkpoint_ids.size
|
|
429
|
+
|
|
430
|
+
# A moving head can fill a quota on every poll. Only work on the saved pages must hold progress.
|
|
431
|
+
select_rows(<<~SQL).empty?
|
|
432
|
+
SELECT 1 FROM #{quoted_table} outbox
|
|
433
|
+
WHERE #{materialization_filter_sql(targets, cursor.checkpoint_ids)}
|
|
434
|
+
LIMIT 1
|
|
435
|
+
SQL
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Revalidate the exact preview under parent locks before locking any deliveries.
|
|
439
|
+
def materialize_batch(limit, targets, preview_rows)
|
|
440
|
+
supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
|
|
441
|
+
preview_ids = preview_rows.map { |row| row_value(row, :id) }
|
|
442
|
+
lock_event_rows!(preview_ids + supersede_parents.map { |row| row_value(row, :event_id) })
|
|
443
|
+
|
|
444
|
+
rows = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: preview_ids))
|
|
445
|
+
return rows if rows.empty?
|
|
446
|
+
|
|
447
|
+
supersede_candidates = select_rows(materialization_supersede_parent_candidates_sql(rows, targets))
|
|
448
|
+
older_event_ids = supersede_candidates.map { |candidate| row_value(candidate, :event_id) }
|
|
449
|
+
older_delivery_ids = supersede_candidates.map { |candidate| row_value(candidate, :delivery_id) }
|
|
450
|
+
lock_delivery_rows!(older_delivery_ids)
|
|
451
|
+
supersede_pending_events_by_id!(older_event_ids)
|
|
452
|
+
supersede_pending_deliveries!(older_delivery_ids, event_ids: older_event_ids)
|
|
453
|
+
execute(delivery_materialization_insert_sql(rows, targets))
|
|
454
|
+
rows
|
|
455
|
+
end
|
|
456
|
+
|
|
418
457
|
def claim_pending_deliveries(limit:, worker_id:)
|
|
419
458
|
rows = claim_pending_delivery_rows(limit: limit, worker_id: worker_id)
|
|
420
459
|
|
|
@@ -431,51 +470,34 @@ module SearchEngine
|
|
|
431
470
|
return collection_limited_materialization_select_sql(targets, event_ids: event_ids, lock: lock)
|
|
432
471
|
end
|
|
433
472
|
|
|
434
|
-
limit = global_limit_for(limit)
|
|
435
473
|
<<~SQL
|
|
436
|
-
|
|
437
|
-
VALUES #{delivery_target_values_sql(targets)}
|
|
438
|
-
),
|
|
439
|
-
candidate_events AS MATERIALIZED (
|
|
440
|
-
SELECT outbox.id,
|
|
441
|
-
outbox.collection,
|
|
442
|
-
outbox.document_id
|
|
443
|
-
FROM #{quoted_table} outbox
|
|
444
|
-
WHERE outbox.status IN ('pending', 'processing', 'failed')
|
|
445
|
-
AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
|
|
446
|
-
#{newer_event_exclusion_sql('outbox')}
|
|
447
|
-
#{event_id_filter_sql('outbox', event_ids)}
|
|
448
|
-
AND EXISTS (
|
|
449
|
-
SELECT 1
|
|
450
|
-
FROM target
|
|
451
|
-
WHERE NOT EXISTS (
|
|
452
|
-
SELECT 1
|
|
453
|
-
FROM #{quoted_delivery_table} deliveries
|
|
454
|
-
WHERE deliveries.event_id = outbox.id
|
|
455
|
-
AND deliveries.target_key = target.target_key
|
|
456
|
-
)
|
|
457
|
-
)
|
|
458
|
-
ORDER BY outbox.id ASC
|
|
459
|
-
LIMIT #{limit}
|
|
460
|
-
#{row_lock_sql(lock)}
|
|
461
|
-
),
|
|
462
|
-
latest_candidate_ids AS (
|
|
463
|
-
SELECT id
|
|
464
|
-
FROM (
|
|
465
|
-
SELECT id,
|
|
466
|
-
ROW_NUMBER() OVER (
|
|
467
|
-
PARTITION BY collection, document_id
|
|
468
|
-
ORDER BY id DESC
|
|
469
|
-
) AS row_number
|
|
470
|
-
FROM candidate_events
|
|
471
|
-
) ranked_candidate_events
|
|
472
|
-
WHERE row_number = 1
|
|
473
|
-
)
|
|
474
|
-
SELECT outbox.*
|
|
474
|
+
SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
|
|
475
475
|
FROM #{quoted_table} outbox
|
|
476
|
-
|
|
477
|
-
ON latest_candidate_ids.id = outbox.id
|
|
476
|
+
WHERE #{materialization_filter_sql(targets, event_ids)}
|
|
478
477
|
ORDER BY outbox.id ASC
|
|
478
|
+
LIMIT #{global_limit_for(limit)}
|
|
479
|
+
#{row_lock_sql(lock)}
|
|
480
|
+
SQL
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Each membership check reads at most one unique delivery per requested key.
|
|
484
|
+
# The scalar newer-event probe also prevents a hash over the entire parent history.
|
|
485
|
+
def materialization_filter_sql(targets, event_ids)
|
|
486
|
+
keys = targets.map(&:key).uniq
|
|
487
|
+
<<~SQL.chomp
|
|
488
|
+
outbox.status IN ('pending', 'processing', 'failed')
|
|
489
|
+
AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
|
|
490
|
+
#{event_id_filter_sql('outbox', event_ids)}
|
|
491
|
+
AND (
|
|
492
|
+
SELECT newer.id FROM #{quoted_table} newer
|
|
493
|
+
WHERE newer.collection = outbox.collection AND newer.document_id = outbox.document_id
|
|
494
|
+
AND newer.id > outbox.id
|
|
495
|
+
LIMIT 1
|
|
496
|
+
) IS NULL
|
|
497
|
+
AND (
|
|
498
|
+
SELECT COUNT(*) FROM #{quoted_delivery_table} deliveries
|
|
499
|
+
WHERE deliveries.event_id = outbox.id AND deliveries.target_key IN (#{ids_sql(keys)})
|
|
500
|
+
) < #{keys.size}
|
|
479
501
|
SQL
|
|
480
502
|
end
|
|
481
503
|
|
|
@@ -683,6 +705,8 @@ module SearchEngine
|
|
|
683
705
|
end
|
|
684
706
|
|
|
685
707
|
def select_scalar(sql)
|
|
708
|
+
# Advisory locks and quiescence polls must reach the current database session on every attempt.
|
|
709
|
+
return connection.exec_query(sql).rows.first&.first if connection.respond_to?(:exec_query)
|
|
686
710
|
return connection.select_value(sql) if connection.respond_to?(:select_value)
|
|
687
711
|
|
|
688
712
|
result = connection.execute(sql)
|
|
@@ -1276,13 +1300,6 @@ module SearchEngine
|
|
|
1276
1300
|
'THEN COALESCE(aggregate.terminal_processed_at, CURRENT_TIMESTAMP) ELSE NULL END'
|
|
1277
1301
|
end
|
|
1278
1302
|
|
|
1279
|
-
def select_rows(sql)
|
|
1280
|
-
result = connection.select_all(sql)
|
|
1281
|
-
return result.to_a if result.respond_to?(:to_a)
|
|
1282
|
-
|
|
1283
|
-
Array(result)
|
|
1284
|
-
end
|
|
1285
|
-
|
|
1286
1303
|
def execute(sql)
|
|
1287
1304
|
connection.execute(sql)
|
|
1288
1305
|
end
|
|
@@ -1594,64 +1611,32 @@ module SearchEngine
|
|
|
1594
1611
|
|
|
1595
1612
|
def collection_limited_materialization_select_sql(targets, event_ids: nil, lock: true)
|
|
1596
1613
|
<<~SQL
|
|
1597
|
-
WITH
|
|
1598
|
-
VALUES #{delivery_target_values_sql(targets)}
|
|
1599
|
-
),
|
|
1600
|
-
#{collection_limits_cte_sql},
|
|
1614
|
+
WITH #{collection_limits_cte_sql},
|
|
1601
1615
|
candidate_events AS MATERIALIZED (
|
|
1602
|
-
SELECT outbox.id,
|
|
1603
|
-
outbox.collection,
|
|
1604
|
-
outbox.document_id
|
|
1616
|
+
SELECT outbox.id, outbox.collection
|
|
1605
1617
|
FROM #{quoted_table} outbox
|
|
1606
|
-
WHERE
|
|
1607
|
-
AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
|
|
1608
|
-
#{newer_event_exclusion_sql('outbox')}
|
|
1609
|
-
#{event_id_filter_sql('outbox', event_ids)}
|
|
1610
|
-
AND EXISTS (
|
|
1611
|
-
SELECT 1
|
|
1612
|
-
FROM target
|
|
1613
|
-
WHERE NOT EXISTS (
|
|
1614
|
-
SELECT 1
|
|
1615
|
-
FROM #{quoted_delivery_table} deliveries
|
|
1616
|
-
WHERE deliveries.event_id = outbox.id
|
|
1617
|
-
AND deliveries.target_key = target.target_key
|
|
1618
|
-
)
|
|
1619
|
-
)
|
|
1618
|
+
WHERE #{materialization_filter_sql(targets, event_ids)}
|
|
1620
1619
|
ORDER BY outbox.id ASC
|
|
1621
1620
|
LIMIT #{collection_limited_candidate_limit}
|
|
1622
1621
|
#{row_lock_sql(lock)}
|
|
1623
1622
|
),
|
|
1624
|
-
latest_candidate_ids AS (
|
|
1625
|
-
SELECT id,
|
|
1626
|
-
collection
|
|
1627
|
-
FROM (
|
|
1628
|
-
SELECT id,
|
|
1629
|
-
collection,
|
|
1630
|
-
ROW_NUMBER() OVER (
|
|
1631
|
-
PARTITION BY collection, document_id
|
|
1632
|
-
ORDER BY id DESC
|
|
1633
|
-
) AS row_number
|
|
1634
|
-
FROM candidate_events
|
|
1635
|
-
) ranked_candidate_events
|
|
1636
|
-
WHERE row_number = 1
|
|
1637
|
-
),
|
|
1638
1623
|
ranked_by_collection AS (
|
|
1639
|
-
SELECT
|
|
1624
|
+
SELECT candidate_events.id,
|
|
1640
1625
|
ROW_NUMBER() OVER (
|
|
1641
|
-
PARTITION BY
|
|
1642
|
-
ORDER BY
|
|
1626
|
+
PARTITION BY candidate_events.collection
|
|
1627
|
+
ORDER BY candidate_events.id ASC
|
|
1643
1628
|
) AS collection_row_number,
|
|
1644
1629
|
COALESCE(collection_limits.batch_size, #{global_limit_for(nil)}) AS collection_batch_size
|
|
1645
|
-
FROM
|
|
1630
|
+
FROM candidate_events
|
|
1646
1631
|
LEFT JOIN collection_limits
|
|
1647
|
-
ON collection_limits.collection =
|
|
1632
|
+
ON collection_limits.collection = candidate_events.collection
|
|
1648
1633
|
),
|
|
1649
1634
|
selected_candidate_ids AS (
|
|
1650
1635
|
SELECT id
|
|
1651
1636
|
FROM ranked_by_collection
|
|
1652
1637
|
WHERE collection_row_number <= collection_batch_size
|
|
1653
1638
|
)
|
|
1654
|
-
SELECT outbox.*
|
|
1639
|
+
SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
|
|
1655
1640
|
FROM #{quoted_table} outbox
|
|
1656
1641
|
INNER JOIN selected_candidate_ids
|
|
1657
1642
|
ON selected_candidate_ids.id = outbox.id
|
|
@@ -1784,18 +1769,6 @@ module SearchEngine
|
|
|
1784
1769
|
Array(ids).compact.uniq(&:to_s)
|
|
1785
1770
|
end
|
|
1786
1771
|
|
|
1787
|
-
def newer_event_exclusion_sql(event_relation)
|
|
1788
|
-
<<~SQL.chomp
|
|
1789
|
-
AND NOT EXISTS (
|
|
1790
|
-
SELECT 1
|
|
1791
|
-
FROM #{quoted_table} newer_events
|
|
1792
|
-
WHERE newer_events.collection = #{event_relation}.collection
|
|
1793
|
-
AND newer_events.document_id = #{event_relation}.document_id
|
|
1794
|
-
AND newer_events.id > #{event_relation}.id
|
|
1795
|
-
)
|
|
1796
|
-
SQL
|
|
1797
|
-
end
|
|
1798
|
-
|
|
1799
1772
|
def processing_event_exclusion_sql(event_relation)
|
|
1800
1773
|
<<~SQL.chomp
|
|
1801
1774
|
AND NOT EXISTS (
|
|
@@ -3,6 +3,8 @@
|
|
|
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'
|
|
7
|
+
require 'search_engine/postgres_outbox/materialization_cursor'
|
|
6
8
|
require 'search_engine/postgres_outbox/repository'
|
|
7
9
|
require 'search_engine/postgres_outbox/event_processor'
|
|
8
10
|
require 'search_engine/postgres_outbox/drainer'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: search-engine-for-typesense
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 30.9.
|
|
4
|
+
version: 30.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nikita Shkoda
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -183,12 +183,14 @@ files:
|
|
|
183
183
|
- lib/search_engine/otel.rb
|
|
184
184
|
- lib/search_engine/partitioner.rb
|
|
185
185
|
- lib/search_engine/postgres_outbox.rb
|
|
186
|
+
- lib/search_engine/postgres_outbox/database_queries.rb
|
|
186
187
|
- lib/search_engine/postgres_outbox/delivery_target.rb
|
|
187
188
|
- lib/search_engine/postgres_outbox/drain_enqueuer.rb
|
|
188
189
|
- lib/search_engine/postgres_outbox/drainer.rb
|
|
189
190
|
- lib/search_engine/postgres_outbox/event.rb
|
|
190
191
|
- lib/search_engine/postgres_outbox/event_processor.rb
|
|
191
192
|
- lib/search_engine/postgres_outbox/listener.rb
|
|
193
|
+
- lib/search_engine/postgres_outbox/materialization_cursor.rb
|
|
192
194
|
- lib/search_engine/postgres_outbox/migration_helpers.rb
|
|
193
195
|
- lib/search_engine/postgres_outbox/processor_result.rb
|
|
194
196
|
- lib/search_engine/postgres_outbox/repository.rb
|