search-engine-for-typesense 30.1.8.7 → 30.1.8.9

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: 77958fc8a7cb84effc7e3a4ca73b2f01e88eacc9ddead6e888c746e17b7e28d5
4
- data.tar.gz: 74a046ef9f7cbdddeeed1c9fb7d9749d501b71300adc30bd0d2d771088aae189
3
+ metadata.gz: 41043de36398de61b1587b3beb737cebbb47712254860e8694220583e08ff4d8
4
+ data.tar.gz: 9d33237f590d99d65a3f457c9a69e10f39184414308d76a6c05dcd58f1c89b77
5
5
  SHA512:
6
- metadata.gz: 9e6bc4e2159d41cd66f0f4f9568d503c3b40ebbbe59be2cb6709ce008589d2e2caa3028f54f3de180a1dc9ff5c37c37cab5ba049780b4583a4974018eaf7ca39
7
- data.tar.gz: a478abb0104cf415d31bcd069d93eb89039acff25c081423117e965fed6d6cd2ffbc859a88601988036246e4b343633e454fda25e27999013c5363e4f824d0c9
6
+ metadata.gz: 19718f2365aee9d80cebd2b282e81c084e4d343177fae938ecf3c817a5cba9ee407058d690d3fe73ffd3216f451821c8492d0a68927234c10739c671396cecc6
7
+ data.tar.gz: 7baabe836610680f7c5cf9b06224fc50dad9f19661ccf3769d2ed732d84669045e180a93d47dbd1240e597bec10f9cd001a497124364c9595286906b8ca84215
data/README.md CHANGED
@@ -217,6 +217,7 @@ SearchEngine.configure do |c|
217
217
  c.postgres_outbox.enabled = true
218
218
  c.postgres_outbox.listener_enabled = -> { Rails.env.production? }
219
219
  c.postgres_outbox.table_name = "search_engine_outbox_events"
220
+ c.postgres_outbox.delivery_table_name = "search_engine_outbox_deliveries"
220
221
  c.postgres_outbox.channel = "search_engine_outbox"
221
222
  c.postgres_outbox.queue_name = "search_engine"
222
223
  c.postgres_outbox.batch_size = 1000
@@ -225,6 +226,14 @@ SearchEngine.configure do |c|
225
226
 
226
227
  # Optional. Leave off when your deployment already guarantees one listener.
227
228
  c.postgres_outbox.advisory_lock = false
229
+
230
+ # Optional. Leave empty for the default single-target flow.
231
+ c.postgres_outbox.delivery_targets = lambda do
232
+ [
233
+ { key: :mirror_a, queue_name: :search_engine_mirror_a },
234
+ { key: :mirror_b, queue_name: :search_engine_mirror_b }
235
+ ]
236
+ end
228
237
  end
229
238
  ```
230
239
 
@@ -242,6 +251,8 @@ class CreateSearchEngineOutboxEvents < ActiveRecord::Migration[7.1]
242
251
 
243
252
  def change
244
253
  create_search_engine_outbox_events
254
+ # Required only when c.postgres_outbox.delivery_targets is configured.
255
+ create_search_engine_outbox_deliveries
245
256
  end
246
257
  end
247
258
  ```
@@ -278,6 +289,13 @@ end
278
289
  `record_id_sql` and `document_id_sql` are trusted migration SQL expressions. They may refer to the
279
290
  PL/pgSQL `record_data` variable, which is `NEW` for inserts/updates and `OLD` for deletes.
280
291
 
292
+ The event table stores logical changes. When `delivery_targets` is empty, drain jobs claim those event rows
293
+ directly and existing single-target setups do not need to create or use delivery rows. When you configure
294
+ delivery targets, `search_engine_outbox_deliveries` stores target-specific status, retry, lock, and queue
295
+ state for each logical event. The listener uses the drain enqueuer to materialize missing delivery rows and
296
+ enqueue one drain job per target queue. Processors still receive event objects and return event IDs; the
297
+ parent event status is refreshed from the aggregate delivery states.
298
+
281
299
  Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
282
300
  write to Typesense for the same changes:
283
301
 
@@ -10,25 +10,55 @@ module SearchEngine
10
10
 
11
11
  # Drain pending outbox events once when PostgreSQL outbox processing is enabled.
12
12
  # @param limit [Integer, nil] optional maximum number of events to claim
13
+ # @param target_key [String, Symbol, nil] optional delivery target scope
13
14
  # @return [Hash, nil]
14
- def perform(limit: nil)
15
+ def perform(limit: nil, target_key: nil)
15
16
  return nil unless SearchEngine.config.postgres_outbox.enabled
16
17
 
17
18
  effective_limit = limit || SearchEngine.config.postgres_outbox.batch_size
18
- drainer = SearchEngine::PostgresOutbox::Drainer.new
19
+ drainer = drainer_for(target_key)
19
20
  summary = drainer.drain_once(limit: effective_limit)
20
- enqueue_continuation(limit: limit) if summary[:claimed].to_i >= effective_limit.to_i
21
+ enqueue_continuation(limit: limit, target_key: target_key) if summary[:claimed].to_i >= effective_limit.to_i
21
22
 
22
23
  summary
23
24
  end
24
25
 
25
26
  private
26
27
 
27
- def enqueue_continuation(limit:)
28
+ def drainer_for(target_key)
29
+ return SearchEngine::PostgresOutbox::Drainer.new if target_key.nil?
30
+
31
+ SearchEngine::PostgresOutbox::Drainer.new(target_key: target_key)
32
+ end
33
+
34
+ def enqueue_continuation(limit:, target_key:)
35
+ return enqueue_target_continuation(limit: limit, target_key: target_key) unless target_key.nil?
28
36
  return self.class.perform_later if limit.nil?
29
37
 
30
38
  self.class.perform_later(limit: limit)
31
39
  end
40
+
41
+ def enqueue_target_continuation(limit:, target_key:)
42
+ target = delivery_target_for!(target_key)
43
+ job = self.class.set(queue: target.queue_name)
44
+ return job.perform_later(target_key: target.key) if limit.nil?
45
+
46
+ job.perform_later(target_key: target.key, limit: limit)
47
+ end
48
+
49
+ def delivery_target_for!(target_key)
50
+ normalized_key = target_key.to_s
51
+ target = delivery_targets.find { |candidate| candidate.key == normalized_key }
52
+ return target if target
53
+
54
+ raise ArgumentError, "unknown postgres outbox delivery target: #{normalized_key}"
55
+ end
56
+
57
+ def delivery_targets
58
+ configured = SearchEngine.config.postgres_outbox.delivery_targets
59
+ raw_targets = configured.respond_to?(:call) ? configured.call : configured
60
+ Array(raw_targets).map { |target| DeliveryTarget.normalize(target) }
61
+ end
32
62
  end
33
63
  end
34
64
  end
@@ -5,5 +5,6 @@ class CreateSearchEngineOutboxEvents < ActiveRecord::Migration[<%= ActiveRecord:
5
5
 
6
6
  def change
7
7
  create_search_engine_outbox_events
8
+ create_search_engine_outbox_deliveries
8
9
  end
9
10
  end
@@ -270,6 +270,8 @@ module SearchEngine
270
270
  attr_accessor :enabled
271
271
  # @return [String] database table used by host-managed outbox events
272
272
  attr_accessor :table_name
273
+ # @return [String] database table used by host-managed outbox deliveries
274
+ attr_accessor :delivery_table_name
273
275
  # @return [String] PostgreSQL notification channel for wakeups
274
276
  attr_accessor :channel
275
277
  # @return [String] queue name used by host app job dispatch
@@ -296,10 +298,13 @@ module SearchEngine
296
298
  attr_accessor :collection_processors
297
299
  # @return [#call] retry backoff calculator receiving the attempt number
298
300
  attr_accessor :retry_backoff
301
+ # @return [#call] resolver returning configured delivery targets
302
+ attr_accessor :delivery_targets
299
303
 
300
304
  def initialize
301
305
  @enabled = false
302
306
  @table_name = 'search_engine_outbox_events'
307
+ @delivery_table_name = 'search_engine_outbox_deliveries'
303
308
  @channel = 'search_engine_outbox'
304
309
  @queue_name = 'search_engine'
305
310
  @batch_size = 1000
@@ -313,6 +318,7 @@ module SearchEngine
313
318
  @listener_enabled = -> { false }
314
319
  @collection_processors = {}
315
320
  @retry_backoff = ->(attempt) { [attempt.to_i, 1].max * 5 }
321
+ @delivery_targets = -> { [] }
316
322
  end
317
323
  end
318
324
 
@@ -863,6 +869,7 @@ module SearchEngine
863
869
  {
864
870
  enabled: postgres_outbox.enabled ? true : false,
865
871
  table_name: postgres_outbox.table_name,
872
+ delivery_table_name: postgres_outbox.delivery_table_name,
866
873
  channel: postgres_outbox.channel,
867
874
  queue_name: postgres_outbox.queue_name,
868
875
  batch_size: postgres_outbox.batch_size,
@@ -875,7 +882,8 @@ module SearchEngine
875
882
  advisory_lock_key: postgres_outbox.advisory_lock_key,
876
883
  listener_enabled: postgres_outbox.listener_enabled,
877
884
  collection_processors: postgres_outbox.collection_processors,
878
- retry_backoff: postgres_outbox.retry_backoff
885
+ retry_backoff: postgres_outbox.retry_backoff,
886
+ delivery_targets: postgres_outbox.delivery_targets
879
887
  }
880
888
  end
881
889
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module PostgresOutbox
5
+ # Generic destination for PostgreSQL outbox delivery processing.
6
+ class DeliveryTarget
7
+ # @return [String] stable target identifier stored in delivery rows
8
+ attr_reader :key
9
+ # @return [String] ActiveJob queue name used to process this target
10
+ attr_reader :queue_name
11
+
12
+ # @param key [String, Symbol] stable target identifier
13
+ # @param queue_name [String, Symbol] queue name for target-specific drain jobs
14
+ def initialize(key:, queue_name:)
15
+ @key = normalize_value(key, 'key')
16
+ @queue_name = normalize_value(queue_name, 'queue_name')
17
+ end
18
+
19
+ # Normalize a configured target into a DeliveryTarget.
20
+ #
21
+ # @param value [DeliveryTarget, Hash, #key] target-like object
22
+ # @return [DeliveryTarget]
23
+ def self.normalize(value)
24
+ return value if value.is_a?(self)
25
+
26
+ if value.respond_to?(:to_hash)
27
+ hash = value.to_hash
28
+ return new(key: fetch_hash_value(hash, :key), queue_name: fetch_hash_value(hash, :queue_name))
29
+ end
30
+
31
+ if value.respond_to?(:key) && value.respond_to?(:queue_name)
32
+ return new(key: value.key, queue_name: value.queue_name)
33
+ end
34
+
35
+ raise ArgumentError, 'delivery target must be a DeliveryTarget, Hash, or target-like object'
36
+ end
37
+
38
+ class << self
39
+ private
40
+
41
+ def fetch_hash_value(hash, key)
42
+ return hash[key] if hash.key?(key)
43
+
44
+ hash[key.to_s]
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def normalize_value(value, name)
51
+ normalized = value.to_s
52
+ raise ArgumentError, "#{name} must be present" if normalized.strip.empty?
53
+
54
+ normalized
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module PostgresOutbox
5
+ # Enqueues PostgreSQL outbox drain jobs for legacy or target-aware delivery mode.
6
+ class DrainEnqueuer
7
+ # Enqueue drain jobs for all configured targets or the legacy queue.
8
+ # @param limit [Integer, nil] optional maximum number of events to claim
9
+ # @return [void]
10
+ def self.enqueue_all(limit: nil)
11
+ new.enqueue_all(limit: limit)
12
+ end
13
+
14
+ # @param repository [SearchEngine::PostgresOutbox::Repository]
15
+ # @param drain_job [#perform_later, #set, nil] ActiveJob-compatible drain job class
16
+ # @param targets_resolver [#call, nil] optional delivery targets resolver
17
+ def initialize(repository: Repository.new, drain_job: nil, targets_resolver: nil)
18
+ @repository = repository
19
+ @drain_job = drain_job
20
+ @targets_resolver = targets_resolver
21
+ end
22
+
23
+ # Enqueue drain jobs for all configured targets or the legacy queue.
24
+ # @param limit [Integer, nil] optional maximum number of events to claim
25
+ # @return [void]
26
+ def enqueue_all(limit: nil)
27
+ targets = delivery_targets
28
+ return enqueue_legacy(limit: limit) if targets.empty?
29
+
30
+ repository.materialize_deliveries!
31
+ targets.each { |target| enqueue_target(target, limit: limit) }
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :repository, :targets_resolver
38
+
39
+ def enqueue_legacy(limit:)
40
+ return drain_job.perform_later if limit.nil?
41
+
42
+ drain_job.perform_later(limit: limit)
43
+ end
44
+
45
+ def enqueue_target(target, limit:)
46
+ job = drain_job.set(queue: target.queue_name)
47
+ return job.perform_later(target_key: target.key) if limit.nil?
48
+
49
+ job.perform_later(target_key: target.key, limit: limit)
50
+ end
51
+
52
+ def delivery_targets
53
+ raw_targets = targets_resolver ? targets_resolver.call : configured_delivery_targets
54
+ Array(raw_targets).map { |target| DeliveryTarget.normalize(target) }
55
+ end
56
+
57
+ def configured_delivery_targets
58
+ configured = SearchEngine.config.postgres_outbox.delivery_targets
59
+ configured.respond_to?(:call) ? configured.call : configured
60
+ end
61
+
62
+ def drain_job
63
+ @drain_job ||= SearchEngine::PostgresOutbox::DrainJob
64
+ end
65
+ end
66
+ end
67
+ end
@@ -11,8 +11,10 @@ module SearchEngine
11
11
  # @param repository [SearchEngine::PostgresOutbox::Repository]
12
12
  # @param processor [#call]
13
13
  # @param worker_id [String, nil]
14
- def initialize(repository: Repository.new, processor: EventProcessor, worker_id: nil)
15
- @repository = repository
14
+ # @param target_key [String, Symbol, nil] optional delivery target scope
15
+ def initialize(repository: nil, processor: EventProcessor, worker_id: nil, target_key: nil)
16
+ @target_key = normalize_optional_target_key(target_key)
17
+ @repository = repository || Repository.new(target_key: @target_key)
16
18
  @processor = processor
17
19
  @worker_id = worker_id || default_worker_id
18
20
  end
@@ -21,7 +23,10 @@ module SearchEngine
21
23
  # @param limit [Integer]
22
24
  # @return [Hash]
23
25
  def drain_once(limit: SearchEngine.config.postgres_outbox.batch_size)
24
- SearchEngine::Instrumentation.instrument('search_engine.postgres_outbox.drain', limit: limit) do |payload|
26
+ SearchEngine::Instrumentation.instrument(
27
+ 'search_engine.postgres_outbox.drain',
28
+ drain_payload(limit)
29
+ ) do |payload|
25
30
  events = repository.claim_pending(limit: limit, worker_id: worker_id)
26
31
  summary = empty_summary(events)
27
32
  next summary if events.empty?
@@ -39,10 +44,10 @@ module SearchEngine
39
44
 
40
45
  private
41
46
 
42
- attr_reader :repository, :processor, :worker_id
47
+ attr_reader :repository, :processor, :worker_id, :target_key
43
48
 
44
49
  def empty_summary(events)
45
- {
50
+ summary = {
46
51
  claimed: events.size,
47
52
  processed: 0,
48
53
  superseded: 0,
@@ -50,6 +55,8 @@ module SearchEngine
50
55
  failed: 0,
51
56
  collections: []
52
57
  }
58
+ summary[:target_key] = target_key if target_key
59
+ summary
53
60
  end
54
61
 
55
62
  def coalesce(events)
@@ -109,7 +116,7 @@ module SearchEngine
109
116
  end
110
117
 
111
118
  def call_processor(collection, events)
112
- result = processor_for(collection).call(events: events, context: { worker_id: worker_id })
119
+ result = processor_for(collection).call(events: events, context: processor_context)
113
120
  normalize_result(result, events)
114
121
  end
115
122
 
@@ -171,6 +178,25 @@ module SearchEngine
171
178
  def default_worker_id
172
179
  "#{Socket.gethostname}:#{$PROCESS_ID}:#{Thread.current.object_id}"
173
180
  end
181
+
182
+ def processor_context
183
+ context = { worker_id: worker_id }
184
+ context[:target_key] = target_key if target_key
185
+ context
186
+ end
187
+
188
+ def drain_payload(limit)
189
+ payload = { limit: limit }
190
+ payload[:target_key] = target_key if target_key
191
+ payload
192
+ end
193
+
194
+ def normalize_optional_target_key(value)
195
+ normalized = value&.to_s
196
+ return nil if normalized.nil? || normalized.strip.empty?
197
+
198
+ normalized
199
+ end
174
200
  end
175
201
  end
176
202
  end
@@ -15,7 +15,9 @@ module SearchEngine
15
15
  :operation,
16
16
  :attempts,
17
17
  :payload,
18
- :created_at
18
+ :created_at,
19
+ :delivery_id,
20
+ :target_key
19
21
 
20
22
  # @param row [Hash] outbox row with string or symbol keys
21
23
  # @raise [ArgumentError] when operation is not upsert/delete
@@ -27,9 +29,11 @@ module SearchEngine
27
29
  @record_id = string_value(row, :record_id)
28
30
  @document_id = string_value(row, :document_id)
29
31
  @operation = normalize_operation(value(row, :operation))
30
- @attempts = value(row, :attempts).to_i
32
+ @attempts = (value(row, :delivery_attempts) || value(row, :attempts)).to_i
31
33
  @payload = value(row, :payload) || {}
32
34
  @created_at = value(row, :created_at)
35
+ @delivery_id = value(row, :delivery_id)
36
+ @target_key = string_value(row, :target_key)
33
37
  end
34
38
 
35
39
  # @return [Array<String>] key used by drainer coalescing
@@ -10,6 +10,7 @@ module SearchEngine
10
10
 
11
11
  # @param connection_pool [#with_connection, nil] ActiveRecord connection pool
12
12
  # @param drain_job [#perform_later] job class used to nudge outbox draining
13
+ # @param drain_enqueuer [#enqueue_all, nil] enqueue coordinator for drain jobs
13
14
  # @param channel [String] PostgreSQL notification channel
14
15
  # @param wait_timeout_s [Numeric] wait timeout for LISTEN notifications
15
16
  # @param poll_interval_s [Numeric] fallback/retry polling interval
@@ -19,6 +20,7 @@ module SearchEngine
19
20
  def initialize(
20
21
  connection_pool: nil,
21
22
  drain_job: nil,
23
+ drain_enqueuer: nil,
22
24
  channel: SearchEngine.config.postgres_outbox.channel,
23
25
  wait_timeout_s: SearchEngine.config.postgres_outbox.listener_wait_timeout_s,
24
26
  poll_interval_s: SearchEngine.config.postgres_outbox.poll_interval_s,
@@ -28,6 +30,7 @@ module SearchEngine
28
30
  )
29
31
  @connection_pool = connection_pool
30
32
  @drain_job = drain_job
33
+ @drain_enqueuer = drain_enqueuer
31
34
  @channel = channel.to_s
32
35
  @wait_timeout_s = wait_timeout_s.to_f
33
36
  @poll_interval_s = poll_interval_s.to_f
@@ -191,7 +194,7 @@ module SearchEngine
191
194
  def enqueue_drain(force: false)
192
195
  return if enqueue_throttled?(force: force)
193
196
 
194
- drain_job.perform_later
197
+ drain_enqueuer.enqueue_all
195
198
  end
196
199
 
197
200
  def enqueue_throttled?(force:)
@@ -219,6 +222,10 @@ module SearchEngine
219
222
  @drain_job ||= SearchEngine::PostgresOutbox::DrainJob
220
223
  end
221
224
 
225
+ def drain_enqueuer
226
+ @drain_enqueuer ||= SearchEngine::PostgresOutbox::DrainEnqueuer.new(drain_job: drain_job)
227
+ end
228
+
222
229
  def connection_pool
223
230
  @connection_pool ||= ::ActiveRecord::Base.connection_pool
224
231
  end
@@ -52,6 +52,53 @@ module SearchEngine
52
52
  where: "status IN ('processed', 'superseded')"
53
53
  end
54
54
 
55
+ # Create the durable SearchEngine outbox deliveries table.
56
+ #
57
+ # @param table_name [String, Symbol] destination delivery table name
58
+ # @param events_table_name [String, Symbol] source events table name
59
+ # @return [void]
60
+ def create_search_engine_outbox_deliveries(
61
+ table_name: SearchEngine.config.postgres_outbox.delivery_table_name,
62
+ events_table_name: SearchEngine.config.postgres_outbox.table_name
63
+ )
64
+ create_table table_name do |t|
65
+ t.bigint :event_id, null: false
66
+ t.string :target_key, null: false
67
+ t.string :queue_name, null: false
68
+ t.string :status, null: false, default: 'pending'
69
+ t.integer :attempts, null: false, default: 0
70
+ t.datetime :locked_at
71
+ t.datetime :next_attempt_at
72
+ t.datetime :processed_at
73
+ t.string :locked_by
74
+ t.text :last_error
75
+ t.timestamps
76
+ end
77
+
78
+ add_index table_name,
79
+ %i[event_id target_key],
80
+ name: 'idx_se_outbox_deliveries_unique',
81
+ unique: true
82
+ add_index table_name,
83
+ %i[target_key status next_attempt_at id],
84
+ name: 'idx_se_outbox_deliveries_pending'
85
+ add_index table_name,
86
+ %i[target_key status event_id],
87
+ name: 'idx_se_outbox_deliveries_coalescing'
88
+ add_index table_name,
89
+ :locked_at,
90
+ name: 'idx_se_outbox_deliveries_processing',
91
+ where: "status = 'processing'"
92
+ add_index table_name,
93
+ :processed_at,
94
+ name: 'idx_se_outbox_deliveries_cleanup',
95
+ where: "status IN ('processed', 'superseded')"
96
+ add_foreign_key table_name,
97
+ events_table_name,
98
+ column: :event_id,
99
+ on_delete: :cascade
100
+ end
101
+
55
102
  # Create or replace a row-level PostgreSQL trigger that writes outbox events.
56
103
  #
57
104
  # @param table_name [String, Symbol] source table name
@@ -7,8 +7,10 @@ module SearchEngine
7
7
  ERROR_LIMIT = 1000
8
8
 
9
9
  # @param connection [Object, nil] ActiveRecord-compatible connection
10
- def initialize(connection: nil)
10
+ # @param target_key [String, Symbol, nil] optional delivery target scope
11
+ def initialize(connection: nil, target_key: nil)
11
12
  @connection = connection
13
+ @target_key = normalize_optional_target_key(target_key)
12
14
  end
13
15
 
14
16
  # Claim pending rows for one worker and return event objects.
@@ -16,6 +18,8 @@ module SearchEngine
16
18
  # @param worker_id [String]
17
19
  # @return [Array<SearchEngine::PostgresOutbox::Event>]
18
20
  def claim_pending(limit:, worker_id:)
21
+ return claim_pending_deliveries(limit: limit, worker_id: worker_id) if delivery_mode?
22
+
19
23
  reset_stale_processing!
20
24
  rows = []
21
25
 
@@ -32,6 +36,8 @@ module SearchEngine
32
36
  # Reset timed-out processing rows to pending.
33
37
  # @return [void]
34
38
  def reset_stale_processing!
39
+ return reset_stale_delivery_processing! if delivery_mode?
40
+
35
41
  execute(<<~SQL)
36
42
  UPDATE #{quoted_table}
37
43
  SET status = 'pending',
@@ -46,12 +52,20 @@ module SearchEngine
46
52
  # @param event_ids [Array<Integer, String>]
47
53
  # @return [void]
48
54
  def mark_processed!(event_ids)
55
+ if delivery_mode?
56
+ return update_delivery_status!(event_ids, 'processed', extra: 'processed_at = CURRENT_TIMESTAMP')
57
+ end
58
+
49
59
  update_status!(event_ids, 'processed', extra: 'processed_at = CURRENT_TIMESTAMP')
50
60
  end
51
61
 
52
62
  # @param event_ids [Array<Integer, String>]
53
63
  # @return [void]
54
64
  def mark_superseded!(event_ids)
65
+ if delivery_mode?
66
+ return update_delivery_status!(event_ids, 'superseded', extra: 'processed_at = CURRENT_TIMESTAMP')
67
+ end
68
+
55
69
  update_status!(event_ids, 'superseded', extra: 'processed_at = CURRENT_TIMESTAMP')
56
70
  end
57
71
 
@@ -61,6 +75,7 @@ module SearchEngine
61
75
  def mark_retryable!(event_ids, error:)
62
76
  ids = Array(event_ids).compact
63
77
  return if ids.empty?
78
+ return mark_delivery_retryable!(ids, error: error) if delivery_mode?
64
79
 
65
80
  execute(<<~SQL)
66
81
  UPDATE #{quoted_table}
@@ -81,6 +96,7 @@ module SearchEngine
81
96
  def mark_failed!(event_ids, error:)
82
97
  ids = Array(event_ids).compact
83
98
  return if ids.empty?
99
+ return mark_delivery_failed!(ids, error: error) if delivery_mode?
84
100
 
85
101
  execute(<<~SQL)
86
102
  UPDATE #{quoted_table}
@@ -93,8 +109,42 @@ module SearchEngine
93
109
  SQL
94
110
  end
95
111
 
112
+ # Create missing delivery rows for all configured delivery targets.
113
+ # @return [void]
114
+ def materialize_deliveries!
115
+ targets = delivery_targets
116
+ return if targets.empty?
117
+
118
+ execute(<<~SQL)
119
+ INSERT INTO #{quoted_delivery_table} (
120
+ event_id,
121
+ target_key,
122
+ queue_name,
123
+ status,
124
+ attempts,
125
+ created_at,
126
+ updated_at
127
+ )
128
+ SELECT outbox.id,
129
+ target.target_key,
130
+ target.queue_name,
131
+ 'pending',
132
+ 0,
133
+ CURRENT_TIMESTAMP,
134
+ CURRENT_TIMESTAMP
135
+ FROM #{quoted_table} outbox
136
+ CROSS JOIN (
137
+ VALUES #{delivery_target_values_sql(targets)}
138
+ ) AS target(target_key, queue_name)
139
+ WHERE outbox.status IN ('pending', 'processing', 'failed')
140
+ ON CONFLICT (event_id, target_key) DO NOTHING
141
+ SQL
142
+ end
143
+
96
144
  private
97
145
 
146
+ attr_reader :target_key
147
+
98
148
  def connection
99
149
  @connection ||= begin
100
150
  require 'active_record'
@@ -102,6 +152,34 @@ module SearchEngine
102
152
  end
103
153
  end
104
154
 
155
+ def claim_pending_deliveries(limit:, worker_id:)
156
+ materialize_deliveries!
157
+ reset_stale_delivery_processing!
158
+ rows = []
159
+
160
+ connection.transaction do
161
+ rows = select_rows(delivery_claim_select_sql(limit.to_i))
162
+ delivery_ids = rows.map { |row| row_value(row, :delivery_id) }
163
+ execute(delivery_supersede_older_pending_sql(rows)) unless rows.empty?
164
+ execute(delivery_claim_update_sql(delivery_ids, worker_id)) unless delivery_ids.empty?
165
+ end
166
+
167
+ rows.map { |row| Event.new(row) }
168
+ end
169
+
170
+ def reset_stale_delivery_processing!
171
+ execute(<<~SQL)
172
+ UPDATE #{quoted_delivery_table}
173
+ SET status = 'pending',
174
+ locked_at = NULL,
175
+ locked_by = NULL,
176
+ updated_at = CURRENT_TIMESTAMP
177
+ WHERE target_key = #{quote(target_key)}
178
+ AND status = 'processing'
179
+ AND locked_at < (CURRENT_TIMESTAMP - interval '#{processing_timeout_s} seconds')
180
+ SQL
181
+ end
182
+
105
183
  def claim_select_sql(limit)
106
184
  <<~SQL
107
185
  WITH ranked_pending AS (
@@ -143,6 +221,56 @@ module SearchEngine
143
221
  SQL
144
222
  end
145
223
 
224
+ def delivery_claim_select_sql(limit)
225
+ <<~SQL
226
+ WITH ranked_pending AS (
227
+ SELECT deliveries.id AS delivery_id,
228
+ events.id AS event_id,
229
+ ROW_NUMBER() OVER (
230
+ PARTITION BY deliveries.target_key, events.collection, events.document_id
231
+ ORDER BY events.id DESC, deliveries.id DESC
232
+ ) AS row_number
233
+ FROM #{quoted_delivery_table} deliveries
234
+ INNER JOIN #{quoted_table} events
235
+ ON events.id = deliveries.event_id
236
+ WHERE deliveries.target_key = #{quote(target_key)}
237
+ AND deliveries.status = 'pending'
238
+ ),
239
+ latest_due AS (
240
+ SELECT deliveries.id
241
+ FROM #{quoted_delivery_table} deliveries
242
+ INNER JOIN ranked_pending
243
+ ON ranked_pending.delivery_id = deliveries.id
244
+ WHERE ranked_pending.row_number = 1
245
+ AND (deliveries.next_attempt_at IS NULL OR deliveries.next_attempt_at <= CURRENT_TIMESTAMP)
246
+ ORDER BY deliveries.id ASC
247
+ LIMIT #{limit}
248
+ )
249
+ SELECT events.*,
250
+ deliveries.id AS delivery_id,
251
+ deliveries.target_key,
252
+ deliveries.attempts AS delivery_attempts
253
+ FROM #{quoted_delivery_table} deliveries
254
+ INNER JOIN #{quoted_table} events
255
+ ON events.id = deliveries.event_id
256
+ INNER JOIN latest_due
257
+ ON latest_due.id = deliveries.id
258
+ ORDER BY deliveries.id ASC
259
+ FOR UPDATE SKIP LOCKED
260
+ SQL
261
+ end
262
+
263
+ def delivery_claim_update_sql(delivery_ids, worker_id)
264
+ <<~SQL
265
+ UPDATE #{quoted_delivery_table}
266
+ SET status = 'processing',
267
+ locked_at = CURRENT_TIMESTAMP,
268
+ locked_by = #{quote(worker_id)},
269
+ updated_at = CURRENT_TIMESTAMP
270
+ WHERE id IN (#{ids_sql(delivery_ids)})
271
+ SQL
272
+ end
273
+
146
274
  def supersede_older_pending_sql(rows)
147
275
  <<~SQL
148
276
  UPDATE #{quoted_table} older
@@ -161,6 +289,43 @@ module SearchEngine
161
289
  SQL
162
290
  end
163
291
 
292
+ def delivery_supersede_older_pending_sql(rows)
293
+ <<~SQL
294
+ WITH updated_deliveries AS (
295
+ UPDATE #{quoted_delivery_table} older_deliveries
296
+ SET status = 'superseded',
297
+ processed_at = CURRENT_TIMESTAMP,
298
+ locked_at = NULL,
299
+ locked_by = NULL,
300
+ updated_at = CURRENT_TIMESTAMP
301
+ FROM #{quoted_table} older_events,
302
+ (
303
+ VALUES #{delivery_coalesce_values_sql(rows)}
304
+ ) AS latest(target_key, collection, document_id, event_id, delivery_id)
305
+ WHERE older_deliveries.event_id = older_events.id
306
+ AND older_deliveries.status = 'pending'
307
+ AND older_deliveries.target_key = latest.target_key
308
+ AND older_events.collection = latest.collection
309
+ AND older_events.document_id = latest.document_id
310
+ AND older_events.id < latest.event_id
311
+ RETURNING older_deliveries.event_id
312
+ ),
313
+ aggregate AS (
314
+ #{event_status_aggregate_sql('SELECT event_id FROM updated_deliveries')}
315
+ )
316
+ UPDATE #{quoted_table} events
317
+ SET status = aggregate.status,
318
+ processed_at = CASE
319
+ WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
320
+ ELSE NULL
321
+ END,
322
+ last_error = aggregate.last_error,
323
+ updated_at = CURRENT_TIMESTAMP
324
+ FROM aggregate
325
+ WHERE events.id = aggregate.event_id
326
+ SQL
327
+ end
328
+
164
329
  def update_status!(event_ids, status, extra:)
165
330
  ids = Array(event_ids).compact
166
331
  return if ids.empty?
@@ -176,6 +341,90 @@ module SearchEngine
176
341
  SQL
177
342
  end
178
343
 
344
+ def update_delivery_status!(event_ids, status, extra:)
345
+ ids = Array(event_ids).compact
346
+ return if ids.empty?
347
+
348
+ execute(<<~SQL)
349
+ UPDATE #{quoted_delivery_table}
350
+ SET status = #{quote(status)},
351
+ #{extra},
352
+ locked_at = NULL,
353
+ locked_by = NULL,
354
+ updated_at = CURRENT_TIMESTAMP
355
+ WHERE target_key = #{quote(target_key)}
356
+ AND event_id IN (#{ids_sql(ids)})
357
+ SQL
358
+ refresh_event_statuses!(ids)
359
+ end
360
+
361
+ def mark_delivery_retryable!(event_ids, error:)
362
+ execute(<<~SQL)
363
+ UPDATE #{quoted_delivery_table}
364
+ SET attempts = attempts + 1,
365
+ status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
366
+ next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
367
+ locked_at = NULL,
368
+ locked_by = NULL,
369
+ last_error = #{quote(truncate_error(error))},
370
+ updated_at = CURRENT_TIMESTAMP
371
+ WHERE target_key = #{quote(target_key)}
372
+ AND event_id IN (#{ids_sql(event_ids)})
373
+ SQL
374
+ refresh_event_statuses!(event_ids)
375
+ end
376
+
377
+ def mark_delivery_failed!(event_ids, error:)
378
+ execute(<<~SQL)
379
+ UPDATE #{quoted_delivery_table}
380
+ SET status = 'failed',
381
+ locked_at = NULL,
382
+ locked_by = NULL,
383
+ last_error = #{quote(truncate_error(error))},
384
+ updated_at = CURRENT_TIMESTAMP
385
+ WHERE target_key = #{quote(target_key)}
386
+ AND event_id IN (#{ids_sql(event_ids)})
387
+ SQL
388
+ refresh_event_statuses!(event_ids)
389
+ end
390
+
391
+ def refresh_event_statuses!(event_ids)
392
+ ids = Array(event_ids).compact
393
+ return if ids.empty?
394
+
395
+ execute(<<~SQL)
396
+ UPDATE #{quoted_table} events
397
+ SET status = aggregate.status,
398
+ processed_at = CASE
399
+ WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
400
+ ELSE NULL
401
+ END,
402
+ last_error = aggregate.last_error,
403
+ updated_at = CURRENT_TIMESTAMP
404
+ FROM (
405
+ #{event_status_aggregate_sql(ids_sql(ids))}
406
+ ) aggregate
407
+ WHERE events.id = aggregate.event_id
408
+ SQL
409
+ end
410
+
411
+ def event_status_aggregate_sql(event_ids_sql)
412
+ <<~SQL.chomp
413
+ SELECT event_id,
414
+ CASE
415
+ WHEN COUNT(*) FILTER (WHERE status = 'failed') > 0 THEN 'failed'
416
+ WHEN COUNT(*) FILTER (WHERE status IN ('pending', 'processing')) > 0 THEN 'pending'
417
+ WHEN COUNT(*) FILTER (WHERE status = 'superseded') = COUNT(*) THEN 'superseded'
418
+ WHEN COUNT(*) FILTER (WHERE status = 'processed') > 0 THEN 'processed'
419
+ ELSE 'pending'
420
+ END AS status,
421
+ (ARRAY_AGG(last_error ORDER BY updated_at DESC) FILTER (WHERE last_error IS NOT NULL))[1] AS last_error
422
+ FROM #{quoted_delivery_table}
423
+ WHERE event_id IN (#{event_ids_sql})
424
+ GROUP BY event_id
425
+ SQL
426
+ end
427
+
179
428
  def select_rows(sql)
180
429
  result = connection.select_all(sql)
181
430
  return result.to_a if result.respond_to?(:to_a)
@@ -201,10 +450,32 @@ module SearchEngine
201
450
  end.join(', ')
202
451
  end
203
452
 
453
+ def delivery_coalesce_values_sql(rows)
454
+ rows.map do |row|
455
+ target = row_value(row, :target_key)
456
+ collection = row_value(row, :collection)
457
+ document_id = row_value(row, :document_id)
458
+ event_id = row_value(row, :id)
459
+ delivery_id = row_value(row, :delivery_id)
460
+
461
+ "(#{quote(target)}, #{quote(collection)}, #{quote(document_id)}, #{quote(event_id)}, #{quote(delivery_id)})"
462
+ end.join(', ')
463
+ end
464
+
465
+ def delivery_target_values_sql(targets)
466
+ targets.map do |target|
467
+ "(#{quote(target.key)}, #{quote(target.queue_name)})"
468
+ end.join(', ')
469
+ end
470
+
204
471
  def quoted_table
205
472
  connection.quote_table_name(SearchEngine.config.postgres_outbox.table_name)
206
473
  end
207
474
 
475
+ def quoted_delivery_table
476
+ connection.quote_table_name(SearchEngine.config.postgres_outbox.delivery_table_name)
477
+ end
478
+
208
479
  def quote(value)
209
480
  connection.quote(value)
210
481
  end
@@ -213,6 +484,23 @@ module SearchEngine
213
484
  row[key] || row[key.to_s]
214
485
  end
215
486
 
487
+ def delivery_mode?
488
+ !target_key.nil?
489
+ end
490
+
491
+ def normalize_optional_target_key(value)
492
+ normalized = value&.to_s
493
+ return nil if normalized.nil? || normalized.strip.empty?
494
+
495
+ normalized
496
+ end
497
+
498
+ def delivery_targets
499
+ configured = SearchEngine.config.postgres_outbox.delivery_targets
500
+ raw_targets = configured.respond_to?(:call) ? configured.call : configured
501
+ Array(raw_targets).map { |target| DeliveryTarget.normalize(target) }
502
+ end
503
+
216
504
  def max_attempts
217
505
  SearchEngine.config.postgres_outbox.max_attempts.to_i
218
506
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'search_engine/postgres_outbox/event'
4
+ require 'search_engine/postgres_outbox/delivery_target'
4
5
  require 'search_engine/postgres_outbox/processor_result'
5
6
  require 'search_engine/postgres_outbox/repository'
6
7
  require 'search_engine/postgres_outbox/event_processor'
7
8
  require 'search_engine/postgres_outbox/drainer'
9
+ require 'search_engine/postgres_outbox/drain_enqueuer'
8
10
  require 'search_engine/postgres_outbox/listener'
9
11
  require 'search_engine/postgres_outbox/migration_helpers'
10
12
 
@@ -93,6 +93,7 @@ module SearchEngine
93
93
  next unless entry[:document]
94
94
 
95
95
  obj = hydrate(entry[:document])
96
+ attach_curation!(obj, entry)
96
97
  attach_highlighting!(obj, entry)
97
98
  attach_geo_distance!(obj, entry)
98
99
  hydrated << obj
@@ -295,6 +296,15 @@ module SearchEngine
295
296
  end
296
297
  end
297
298
 
299
+ # Per-hit curation mixin: added onto hydrated objects when Typesense
300
+ # returns curated metadata for the hit.
301
+ module HitCuration
302
+ # @return [Boolean] whether Typesense marked this search hit as curated
303
+ def curated_hit?
304
+ instance_variable_get(:@__se_curated_hit__) == true
305
+ end
306
+ end
307
+
298
308
  def parse_facets
299
309
  @__facets_parsed_memo || {}.freeze
300
310
  end
@@ -401,6 +411,7 @@ module SearchEngine
401
411
 
402
412
  obj = hydrate(doc)
403
413
  sym_sub = symbolize_hit(sub)
414
+ attach_curation!(obj, sym_sub)
404
415
  attach_highlighting!(obj, sym_sub)
405
416
  attach_geo_distance!(obj, sym_sub)
406
417
  hydrated << obj
@@ -546,6 +557,18 @@ module SearchEngine
546
557
  obj
547
558
  end
548
559
 
560
+ def attach_curation!(obj, hit_entry)
561
+ return obj unless hit_entry.key?(:curated)
562
+
563
+ value = hit_entry[:curated]
564
+ curated = value == true || value.to_s == 'true'
565
+ obj.extend(HitCuration) unless obj.singleton_class.included_modules.include?(HitCuration)
566
+ obj.instance_variable_set(:@__se_curated_hit__, curated)
567
+ obj
568
+ rescue StandardError
569
+ obj
570
+ end
571
+
549
572
  def attach_geo_distance!(obj, hit_entry)
550
573
  raw_geo = hit_entry[:geo_distance_meters]
551
574
  return obj unless raw_geo.is_a?(Hash) && !raw_geo.empty?
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.8.7'
6
+ VERSION = '30.1.8.9'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: search-engine-for-typesense
3
3
  version: !ruby/object:Gem::Version
4
- version: 30.1.8.7
4
+ version: 30.1.8.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda
@@ -182,6 +182,8 @@ files:
182
182
  - lib/search_engine/otel.rb
183
183
  - lib/search_engine/partitioner.rb
184
184
  - lib/search_engine/postgres_outbox.rb
185
+ - lib/search_engine/postgres_outbox/delivery_target.rb
186
+ - lib/search_engine/postgres_outbox/drain_enqueuer.rb
185
187
  - lib/search_engine/postgres_outbox/drainer.rb
186
188
  - lib/search_engine/postgres_outbox/event.rb
187
189
  - lib/search_engine/postgres_outbox/event_processor.rb