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 +4 -4
- data/README.md +18 -0
- data/app/search_engine/search_engine/postgres_outbox/drain_job.rb +34 -4
- data/lib/generators/search_engine/postgres_outbox/templates/create_outbox_events.rb.tt +1 -0
- data/lib/search_engine/config.rb +9 -1
- data/lib/search_engine/postgres_outbox/delivery_target.rb +58 -0
- data/lib/search_engine/postgres_outbox/drain_enqueuer.rb +67 -0
- data/lib/search_engine/postgres_outbox/drainer.rb +32 -6
- data/lib/search_engine/postgres_outbox/event.rb +6 -2
- data/lib/search_engine/postgres_outbox/listener.rb +8 -1
- data/lib/search_engine/postgres_outbox/migration_helpers.rb +47 -0
- data/lib/search_engine/postgres_outbox/repository.rb +289 -1
- data/lib/search_engine/postgres_outbox.rb +2 -0
- data/lib/search_engine/result.rb +23 -0
- data/lib/search_engine/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41043de36398de61b1587b3beb737cebbb47712254860e8694220583e08ff4d8
|
|
4
|
+
data.tar.gz: 9d33237f590d99d65a3f457c9a69e10f39184414308d76a6c05dcd58f1c89b77
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
|
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
|
data/lib/search_engine/config.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/search_engine/result.rb
CHANGED
|
@@ -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?
|
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.
|
|
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
|