search-engine-for-typesense 30.9.0 → 30.9.1

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: 2f5d1b6315a75f241c80d16c1f69727fed0ceef2edabb8ce774d33c348756a59
4
+ data.tar.gz: fbb2364ac8afc372ff0bbcb8f70174874ab190e226f6d08e1e747b018be8259b
5
5
  SHA512:
6
- metadata.gz: d8d3847c59ab120f22f076d3c76aeebf7ea0e6b74ccf29771b72f30896f8a7789f213c1d139c40a8565ed4e63612adebafafbe81ae25eb1dd5190a7dc6215e92
7
- data.tar.gz: 03700343db8c49a61e5d722992cae22e23ff0dfd51506d98124b2039fd0e7302f0da495914b549e0082718832199778d3ffbedd3d62e89c53f056c56b58c0250
6
+ metadata.gz: 1db71818517bd5dd234a43ebfe63f1b9affa33b4fcb0f6a15914cb6bcd55a3a6844ba8a87e68ead1bbc81a3a87dbe3dba2d845f9ba8c4da6d3251ed877755c5b
7
+ data.tar.gz: 56a54cdd5a7a0fdf3566c021788ba9f4d54aa9311f7df508b8b9e99c5d7010c9ecd26cf2d83cc219625a48d7738883e03c49360c9d7c4d256f4fc48d173e315b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 30.9.1
4
+
5
+ - Serialize PostgreSQL delivery discovery across schedulers and workers and check delivery membership
6
+ by event and target. Hosts can enable checkpointed scans to bound discovery on large outboxes.
7
+ - Execute advisory locks and delivery cutover polls on the database when Rails query caching is enabled.
8
+
3
9
  ## 30.9.0
4
10
 
5
11
  - Qualify geo/vector search and native caching against Typesense 30.2 while keeping the gem major
data/README.md CHANGED
@@ -565,6 +565,22 @@ 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 1,000 IDs around the forward position and
576
+ 1,000 historical IDs against the requested targets. The forward scan overlaps its previous
577
+ range to catch nearby out-of-order commits. Each target set maintains its own position. A
578
+ transaction that commits farther behind, or an older event that becomes due, can wait until
579
+ the historical scan revisits its ID. Sweep time depends on retained history and polling rate.
580
+ Collection batch quotas and the latest-event rule still govern which deliveries may be created.
581
+ Without a configured checkpoint table the existing full-history discovery uses indexed per-event
582
+ delivery checks and the same concurrency lock.
583
+
568
584
  Drain slots are a generic backpressure mechanism for delivery-target mode. On each listener or polling
569
585
  wakeup, the enqueuer materializes missing delivery rows, acquires idle slots from
570
586
  `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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module SearchEngine
7
+ module PostgresOutbox
8
+ # Read under the outbox materialization lock. One page follows new event IDs;
9
+ # the other sweeps backwards to revisit late commits, due retries and existing events.
10
+ class MaterializationCursor
11
+ PAGE_SIZE = 1000
12
+
13
+ def initialize(connection, events_table, cursor_table, target_keys)
14
+ @connection = connection
15
+ @events = events_table
16
+ @table = connection.quote_table_name(cursor_table)
17
+ @scope = connection.quote(Digest::SHA256.hexdigest(JSON.generate([events_table, target_keys.uniq.sort])))
18
+ end
19
+
20
+ # @param limit [Integer] upper bound on the number of event IDs to read in each direction
21
+ # @return [Array<Integer>] event IDs to check against the configured delivery targets
22
+ def candidate_ids(limit:)
23
+ @page_size = [PAGE_SIZE, limit].min
24
+ state = @connection.select_all(<<~SQL).first
25
+ SELECT forward_id, backfill_through_id FROM #{@table} WHERE target_scope = #{@scope}
26
+ SQL
27
+ if state
28
+ @forward_id = Integer(state.fetch('forward_id'))
29
+ @backfill_through_id = Integer(state.fetch('backfill_through_id'))
30
+ else
31
+ @forward_id, @backfill_through_id = initialize_positions
32
+ end
33
+
34
+ # Recheck a recent ID range for concurrent inserts that commit behind the watermark.
35
+ # Even for dense IDs, at least half of the page remains available for new events.
36
+ @forward_page = event_ids("id > #{@forward_id - @page_size / 2} ORDER BY id ASC")
37
+ @backfill_page = event_ids("id <= #{@backfill_through_id} ORDER BY id DESC")
38
+ @forward_page | @backfill_page
39
+ end
40
+
41
+ # Save progress in the same transaction as the new deliveries.
42
+ def advance!
43
+ forward = [@forward_page.last || @forward_id, @forward_id].max
44
+ # Freeze the upper boundary until the backwards sweep reaches the beginning.
45
+ backfill = @backfill_page.size < @page_size ? forward : @backfill_page.last - 1
46
+ return if forward == @forward_id && backfill == @backfill_through_id
47
+
48
+ @connection.execute(<<~SQL)
49
+ UPDATE #{@table} SET forward_id = #{forward}, backfill_through_id = #{backfill}
50
+ WHERE target_scope = #{@scope}
51
+ SQL
52
+ end
53
+
54
+ private
55
+
56
+ def initialize_positions
57
+ head = Integer(@connection.select_value("SELECT COALESCE(MAX(id), 0) FROM #{@events}"))
58
+ @connection.execute(<<~SQL)
59
+ INSERT INTO #{@table} (target_scope, forward_id, backfill_through_id)
60
+ VALUES (#{@scope}, #{head}, #{head})
61
+ SQL
62
+ [head, head]
63
+ end
64
+
65
+ def event_ids(order)
66
+ @connection.select_all("SELECT id FROM #{@events} WHERE #{order} LIMIT #{@page_size}").map do |row|
67
+ Integer(row.fetch('id'))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ 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
@@ -9,6 +9,7 @@ module SearchEngine
9
9
  class Repository
10
10
  # Keep expensive duplicate-key ranking bounded while leaving headroom for hot keys.
11
11
  CLAIM_CANDIDATE_OVERSAMPLE = 4
12
+ MATERIALIZATION_LOCK_NAMESPACE = 0x53454F42 # SEOB
12
13
  ERROR_LIMIT = 1000
13
14
  PROCESSED_AT_ASSIGNMENT = 'processed_at = CURRENT_TIMESTAMP'
14
15
  RETIREMENT_BATCH_SIZE = 1000
@@ -181,43 +182,27 @@ module SearchEngine
181
182
  with_delivery_target_session_lock(target, mode: :shared, timing: timing, &block)
182
183
  end
183
184
 
184
- # Create missing delivery rows for all configured delivery targets.
185
- # @return [void]
185
+ # Create missing deliveries. Concurrent pollers leave discovery to the current owner.
186
+ # @return [Array<Hash>, nil] materialized events, or nil when no targets are configured
186
187
  def materialize_deliveries!(limit: nil)
187
188
  targets = materialization_delivery_targets
188
189
  return if targets.empty?
189
190
 
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?
191
+ scan_limit = collection_limited_batch?(limit) ? collection_limited_candidate_limit : global_limit_for(limit)
192
+ return [] if scan_limit.zero?
194
193
 
195
- supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
196
- rows = []
197
194
  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))
195
+ next [] unless materialization_lock_acquired?
196
+
197
+ cursor = materialization_cursor(targets)
198
+ event_ids = cursor&.candidate_ids(limit: scan_limit)
199
+ preview = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: event_ids, lock: false))
200
+ rows = preview.empty? ? [] : materialize_batch(limit, targets, preview)
201
+ if cursor && rows.size == preview.size && materialization_page_drained?(rows, event_ids, limit)
202
+ cursor.advance!
203
+ end
204
+ rows
218
205
  end
219
-
220
- rows
221
206
  end
222
207
 
223
208
  # Refresh cleanup-eligible parent event statuses from terminal delivery rows.
@@ -415,6 +400,49 @@ module SearchEngine
415
400
  end
416
401
  end
417
402
 
403
+ # PostgreSQL's two-integer lock space is separate from the single-key delivery cutover locks.
404
+ def materialization_lock_acquired?
405
+ sql = <<~SQL
406
+ SELECT pg_try_advisory_xact_lock(#{MATERIALIZATION_LOCK_NAMESPACE}, #{quote(quoted_table)}::regclass::integer)
407
+ SQL
408
+ advisory_lock_truthy?(select_scalar(sql))
409
+ end
410
+
411
+ def materialization_cursor(targets)
412
+ table = SearchEngine.config.postgres_outbox.materialization_cursor_table_name
413
+ MaterializationCursor.new(connection, quoted_table, table, targets.map(&:key)) if table
414
+ end
415
+
416
+ # When a quota may have omitted matches, revisit the page after these deliveries commit.
417
+ def materialization_page_drained?(rows, event_ids, limit)
418
+ return true if rows.empty? || rows.size == event_ids.size
419
+
420
+ batch_size = global_limit_for(limit)
421
+ return rows.size < batch_size unless collection_limited_batch?(limit)
422
+
423
+ per_collection = SearchEngine.config.postgres_outbox.normalized_batch_sizes.values
424
+ rows.size < [batch_size, *per_collection].min
425
+ end
426
+
427
+ # Revalidate the exact preview under parent locks before locking any deliveries.
428
+ def materialize_batch(limit, targets, preview_rows)
429
+ supersede_parents = select_rows(supersede_parent_ids_sql(preview_rows))
430
+ preview_ids = preview_rows.map { |row| row_value(row, :id) }
431
+ lock_event_rows!(preview_ids + supersede_parents.map { |row| row_value(row, :event_id) })
432
+
433
+ rows = select_rows(delivery_materialization_select_sql(limit, targets, event_ids: preview_ids))
434
+ return rows if rows.empty?
435
+
436
+ supersede_candidates = select_rows(materialization_supersede_parent_candidates_sql(rows, targets))
437
+ older_event_ids = supersede_candidates.map { |candidate| row_value(candidate, :event_id) }
438
+ older_delivery_ids = supersede_candidates.map { |candidate| row_value(candidate, :delivery_id) }
439
+ lock_delivery_rows!(older_delivery_ids)
440
+ supersede_pending_events_by_id!(older_event_ids)
441
+ supersede_pending_deliveries!(older_delivery_ids, event_ids: older_event_ids)
442
+ execute(delivery_materialization_insert_sql(rows, targets))
443
+ rows
444
+ end
445
+
418
446
  def claim_pending_deliveries(limit:, worker_id:)
419
447
  rows = claim_pending_delivery_rows(limit: limit, worker_id: worker_id)
420
448
 
@@ -431,51 +459,34 @@ module SearchEngine
431
459
  return collection_limited_materialization_select_sql(targets, event_ids: event_ids, lock: lock)
432
460
  end
433
461
 
434
- limit = global_limit_for(limit)
435
462
  <<~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.*
463
+ SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
475
464
  FROM #{quoted_table} outbox
476
- INNER JOIN latest_candidate_ids
477
- ON latest_candidate_ids.id = outbox.id
465
+ WHERE #{materialization_filter_sql(targets, event_ids)}
478
466
  ORDER BY outbox.id ASC
467
+ LIMIT #{global_limit_for(limit)}
468
+ #{row_lock_sql(lock)}
469
+ SQL
470
+ end
471
+
472
+ # Each membership check reads at most one unique delivery per requested key.
473
+ # The scalar newer-event probe also prevents a hash over the entire parent history.
474
+ def materialization_filter_sql(targets, event_ids)
475
+ keys = targets.map(&:key).uniq
476
+ <<~SQL.chomp
477
+ outbox.status IN ('pending', 'processing', 'failed')
478
+ AND (outbox.next_attempt_at IS NULL OR outbox.next_attempt_at <= CURRENT_TIMESTAMP)
479
+ #{event_id_filter_sql('outbox', event_ids)}
480
+ AND (
481
+ SELECT newer.id FROM #{quoted_table} newer
482
+ WHERE newer.collection = outbox.collection AND newer.document_id = outbox.document_id
483
+ AND newer.id > outbox.id
484
+ LIMIT 1
485
+ ) IS NULL
486
+ AND (
487
+ SELECT COUNT(*) FROM #{quoted_delivery_table} deliveries
488
+ WHERE deliveries.event_id = outbox.id AND deliveries.target_key IN (#{ids_sql(keys)})
489
+ ) < #{keys.size}
479
490
  SQL
480
491
  end
481
492
 
@@ -683,6 +694,8 @@ module SearchEngine
683
694
  end
684
695
 
685
696
  def select_scalar(sql)
697
+ # Advisory locks and quiescence polls must reach the current database session on every attempt.
698
+ return connection.exec_query(sql).rows.first&.first if connection.respond_to?(:exec_query)
686
699
  return connection.select_value(sql) if connection.respond_to?(:select_value)
687
700
 
688
701
  result = connection.execute(sql)
@@ -1594,64 +1607,32 @@ module SearchEngine
1594
1607
 
1595
1608
  def collection_limited_materialization_select_sql(targets, event_ids: nil, lock: true)
1596
1609
  <<~SQL
1597
- WITH target(target_key, queue_name) AS (
1598
- VALUES #{delivery_target_values_sql(targets)}
1599
- ),
1600
- #{collection_limits_cte_sql},
1610
+ WITH #{collection_limits_cte_sql},
1601
1611
  candidate_events AS MATERIALIZED (
1602
- SELECT outbox.id,
1603
- outbox.collection,
1604
- outbox.document_id
1612
+ SELECT outbox.id, outbox.collection
1605
1613
  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
- )
1614
+ WHERE #{materialization_filter_sql(targets, event_ids)}
1620
1615
  ORDER BY outbox.id ASC
1621
1616
  LIMIT #{collection_limited_candidate_limit}
1622
1617
  #{row_lock_sql(lock)}
1623
1618
  ),
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
1619
  ranked_by_collection AS (
1639
- SELECT latest_candidate_ids.id,
1620
+ SELECT candidate_events.id,
1640
1621
  ROW_NUMBER() OVER (
1641
- PARTITION BY latest_candidate_ids.collection
1642
- ORDER BY latest_candidate_ids.id ASC
1622
+ PARTITION BY candidate_events.collection
1623
+ ORDER BY candidate_events.id ASC
1643
1624
  ) AS collection_row_number,
1644
1625
  COALESCE(collection_limits.batch_size, #{global_limit_for(nil)}) AS collection_batch_size
1645
- FROM latest_candidate_ids
1626
+ FROM candidate_events
1646
1627
  LEFT JOIN collection_limits
1647
- ON collection_limits.collection = latest_candidate_ids.collection
1628
+ ON collection_limits.collection = candidate_events.collection
1648
1629
  ),
1649
1630
  selected_candidate_ids AS (
1650
1631
  SELECT id
1651
1632
  FROM ranked_by_collection
1652
1633
  WHERE collection_row_number <= collection_batch_size
1653
1634
  )
1654
- SELECT outbox.*
1635
+ SELECT #{lock ? 'outbox.*' : 'outbox.id, outbox.collection, outbox.document_id'}
1655
1636
  FROM #{quoted_table} outbox
1656
1637
  INNER JOIN selected_candidate_ids
1657
1638
  ON selected_candidate_ids.id = outbox.id
@@ -1784,18 +1765,6 @@ module SearchEngine
1784
1765
  Array(ids).compact.uniq(&:to_s)
1785
1766
  end
1786
1767
 
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
1768
  def processing_event_exclusion_sql(event_relation)
1800
1769
  <<~SQL.chomp
1801
1770
  AND NOT EXISTS (
@@ -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/materialization_cursor'
6
7
  require 'search_engine/postgres_outbox/repository'
7
8
  require 'search_engine/postgres_outbox/event_processor'
8
9
  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.1'
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.1
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
@@ -189,6 +189,7 @@ files:
189
189
  - lib/search_engine/postgres_outbox/event.rb
190
190
  - lib/search_engine/postgres_outbox/event_processor.rb
191
191
  - lib/search_engine/postgres_outbox/listener.rb
192
+ - lib/search_engine/postgres_outbox/materialization_cursor.rb
192
193
  - lib/search_engine/postgres_outbox/migration_helpers.rb
193
194
  - lib/search_engine/postgres_outbox/processor_result.rb
194
195
  - lib/search_engine/postgres_outbox/repository.rb