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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8363aa6879873e4b2d897181f0e87494b454b5c1ccf51f19d29f4590e992b24
4
- data.tar.gz: bd13ccd5af83d57a4499227629bbc6caa360dd08e15457e8eb80067d174fc20e
3
+ metadata.gz: e2a45aebc5484036b192915f9de295af3efbce20c7831c3e301dd9608b68a26d
4
+ data.tar.gz: e7274a561a6f852f5daa8489f33349bbdf430da811f8e0db3556c36547a1098e
5
5
  SHA512:
6
- metadata.gz: d8d3847c59ab120f22f076d3c76aeebf7ea0e6b74ccf29771b72f30896f8a7789f213c1d139c40a8565ed4e63612adebafafbe81ae25eb1dd5190a7dc6215e92
7
- data.tar.gz: 03700343db8c49a61e5d722992cae22e23ff0dfd51506d98124b2039fd0e7302f0da495914b549e0082718832199778d3ffbedd3d62e89c53f056c56b58c0250
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
@@ -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 delivery rows for all configured delivery targets.
185
- # @return [void]
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
- # Discover work without row locks, then acquire every affected parent before any delivery.
191
- # The exact preview is revalidated under those parent locks before mutations begin.
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
- parent_ids = preview_rows.map { |row| row_value(row, :id) }
199
- parent_ids.concat(supersede_parents.map { |row| row_value(row, :event_id) })
200
- lock_event_rows!(parent_ids)
201
-
202
- rows = select_rows(
203
- delivery_materialization_select_sql(
204
- limit,
205
- targets,
206
- event_ids: preview_rows.map { |row| row_value(row, :id) }
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
- WITH target(target_key, queue_name) AS (
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
- INNER JOIN latest_candidate_ids
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 target(target_key, queue_name) AS (
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 outbox.status IN ('pending', 'processing', 'failed')
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 latest_candidate_ids.id,
1624
+ SELECT candidate_events.id,
1640
1625
  ROW_NUMBER() OVER (
1641
- PARTITION BY latest_candidate_ids.collection
1642
- ORDER BY latest_candidate_ids.id ASC
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 latest_candidate_ids
1630
+ FROM candidate_events
1646
1631
  LEFT JOIN collection_limits
1647
- ON collection_limits.collection = latest_candidate_ids.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'
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.9.0'
6
+ VERSION = '30.9.2'
7
7
  end
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.0
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-02 00:00:00.000000000 Z
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