search-engine-for-typesense 30.1.8.13 → 30.1.8.14
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 +43 -7
- data/app/search_engine/search_engine/postgres_outbox/drain_job.rb +119 -5
- data/lib/generators/search_engine/postgres_outbox/templates/create_outbox_events.rb.tt +1 -0
- data/lib/search_engine/config.rb +17 -1
- data/lib/search_engine/postgres_outbox/delivery_target.rb +17 -3
- data/lib/search_engine/postgres_outbox/drain_enqueuer.rb +16 -0
- data/lib/search_engine/postgres_outbox/migration_helpers.rb +33 -0
- data/lib/search_engine/postgres_outbox/repository.rb +194 -0
- data/lib/search_engine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68b92138a4716df3c14da84a5f66b26f30bbdcb6afcb8d54f19d953c6652d1ed
|
|
4
|
+
data.tar.gz: 7f21c4f3ea8216ad390494f9ca813a3829b80b15b8948f3ea5523f566d101368
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cec597cc88d5553ac833035e7e974ea3f6683d1efeaf1c2a2f2431f004c78a5ccfdee27e4eb22c513a0e95f8c2275e28b2b2158fb6d8d95b3846328f45633e4
|
|
7
|
+
data.tar.gz: 0a3d95f6df6dba0f0ea9bbdb93fe6224a3b5b2d7cefa0907504273a004c095750f1a9a9f7d67b3e60180dcbf019a01ab55bd137fc5cde563c76c28634a401e3b
|
data/README.md
CHANGED
|
@@ -218,9 +218,13 @@ SearchEngine.configure do |c|
|
|
|
218
218
|
c.postgres_outbox.listener_enabled = -> { Rails.env.production? }
|
|
219
219
|
c.postgres_outbox.table_name = "search_engine_outbox_events"
|
|
220
220
|
c.postgres_outbox.delivery_table_name = "search_engine_outbox_deliveries"
|
|
221
|
+
c.postgres_outbox.drain_slot_table_name = "search_engine_outbox_drain_slots"
|
|
221
222
|
c.postgres_outbox.channel = "search_engine_outbox"
|
|
222
223
|
c.postgres_outbox.queue_name = "search_engine"
|
|
223
224
|
c.postgres_outbox.batch_size = 1000
|
|
225
|
+
c.postgres_outbox.drain_target_parallelism = 1
|
|
226
|
+
c.postgres_outbox.drain_job_max_batches = 1
|
|
227
|
+
c.postgres_outbox.drain_job_max_runtime_s = nil
|
|
224
228
|
c.postgres_outbox.poll_interval_s = 5
|
|
225
229
|
c.postgres_outbox.retention_s = 7.days.to_i
|
|
226
230
|
|
|
@@ -230,7 +234,7 @@ SearchEngine.configure do |c|
|
|
|
230
234
|
# Optional. Leave empty for the default single-target flow.
|
|
231
235
|
c.postgres_outbox.delivery_targets = lambda do
|
|
232
236
|
[
|
|
233
|
-
{ key: :mirror_a, queue_name: :search_engine_mirror_a },
|
|
237
|
+
{ key: :mirror_a, queue_name: :search_engine_mirror_a, parallelism: 2 },
|
|
234
238
|
{ key: :mirror_b, queue_name: :search_engine_mirror_b }
|
|
235
239
|
]
|
|
236
240
|
end
|
|
@@ -251,8 +255,9 @@ class CreateSearchEngineOutboxEvents < ActiveRecord::Migration[7.1]
|
|
|
251
255
|
|
|
252
256
|
def change
|
|
253
257
|
create_search_engine_outbox_events
|
|
254
|
-
# Required only when c.postgres_outbox.delivery_targets is configured
|
|
258
|
+
# Required only when c.postgres_outbox.delivery_targets is configured:
|
|
255
259
|
create_search_engine_outbox_deliveries
|
|
260
|
+
create_search_engine_outbox_drain_slots
|
|
256
261
|
end
|
|
257
262
|
end
|
|
258
263
|
```
|
|
@@ -290,11 +295,42 @@ end
|
|
|
290
295
|
PL/pgSQL `record_data` variable, which is `NEW` for inserts/updates and `OLD` for deletes.
|
|
291
296
|
|
|
292
297
|
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
|
|
294
|
-
delivery targets, `search_engine_outbox_deliveries` stores
|
|
295
|
-
state for each logical event.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
directly and existing single-target setups do not need to create or use delivery rows, drain slots, or the
|
|
299
|
+
drain slot table. When you configure delivery targets, `search_engine_outbox_deliveries` stores
|
|
300
|
+
target-specific status, retry, lock, and queue state for each logical event. Add
|
|
301
|
+
`create_search_engine_outbox_drain_slots` in that delivery-target migration so the drain enqueuer can cap
|
|
302
|
+
queued and running drain jobs per target.
|
|
303
|
+
|
|
304
|
+
Drain slots are a generic backpressure mechanism for delivery-target mode. On each listener or polling
|
|
305
|
+
wakeup, the enqueuer materializes missing delivery rows, acquires idle slots from
|
|
306
|
+
`c.postgres_outbox.drain_slot_table_name`, and enqueues one drain job per acquired slot. If all slots for a
|
|
307
|
+
target are already queued or processing, that wakeup does not enqueue another job for the target.
|
|
308
|
+
|
|
309
|
+
`c.postgres_outbox.drain_target_parallelism` controls the default maximum concurrent drain jobs per delivery
|
|
310
|
+
target. Individual targets can override it with `parallelism:`:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
SearchEngine.configure do |c|
|
|
314
|
+
c.postgres_outbox.drain_target_parallelism = 2
|
|
315
|
+
c.postgres_outbox.delivery_targets = [
|
|
316
|
+
{ key: :mirror_a, queue_name: :search_engine_mirror_a },
|
|
317
|
+
{ key: :mirror_b, queue_name: :search_engine_mirror_b, parallelism: 4 }
|
|
318
|
+
]
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Slot-aware drain jobs do finite work. `c.postgres_outbox.drain_job_max_batches` limits how many batches a
|
|
323
|
+
job may drain before yielding, and `c.postgres_outbox.drain_job_max_runtime_s` optionally adds a runtime
|
|
324
|
+
budget. When a slot-aware job reaches either bound while more work remains, it requeues the same slot instead
|
|
325
|
+
of acquiring a new one. When no more work is indicated, it releases the slot back to `idle`.
|
|
326
|
+
|
|
327
|
+
Already-enqueued target jobs without a `drain_slot` argument remain compatible. If the drain slot table
|
|
328
|
+
exists, those old jobs route through the slot-aware enqueuer for their continuations; if the table does not
|
|
329
|
+
exist, the gem falls back to the previous one-job-per-target behavior. This keeps staged rollouts safe while
|
|
330
|
+
hosts add the optional drain slot table.
|
|
331
|
+
|
|
332
|
+
Processors still receive event objects and return event IDs; the parent event status is refreshed from the
|
|
333
|
+
aggregate delivery states.
|
|
298
334
|
|
|
299
335
|
Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
|
|
300
336
|
write to Typesense for the same changes:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
3
5
|
module SearchEngine
|
|
4
6
|
module PostgresOutbox
|
|
5
7
|
# ActiveJob entrypoint for one bounded PostgreSQL outbox drain pass.
|
|
@@ -11,10 +13,14 @@ module SearchEngine
|
|
|
11
13
|
# Drain pending outbox events once when PostgreSQL outbox processing is enabled.
|
|
12
14
|
# @param limit [Integer, nil] optional maximum number of events to claim
|
|
13
15
|
# @param target_key [String, Symbol, nil] optional delivery target scope
|
|
16
|
+
# @param drain_slot [Integer, nil] optional acquired drain slot
|
|
14
17
|
# @return [Hash, nil]
|
|
15
|
-
def perform(limit: nil, target_key: nil)
|
|
18
|
+
def perform(limit: nil, target_key: nil, drain_slot: nil)
|
|
16
19
|
return nil unless SearchEngine.config.postgres_outbox.enabled
|
|
17
20
|
return enqueue_target_drains(limit: limit) if target_key.nil? && delivery_targets.any?
|
|
21
|
+
unless drain_slot.nil?
|
|
22
|
+
return perform_with_drain_slot(limit: limit, target_key: target_key, drain_slot: drain_slot)
|
|
23
|
+
end
|
|
18
24
|
|
|
19
25
|
effective_limit = limit || SearchEngine.config.postgres_outbox.batch_size
|
|
20
26
|
drainer = drainer_for(target_key)
|
|
@@ -31,6 +37,57 @@ module SearchEngine
|
|
|
31
37
|
{ claimed: 0, processed: 0, enqueued_targets: delivery_targets.size }
|
|
32
38
|
end
|
|
33
39
|
|
|
40
|
+
def perform_with_drain_slot(limit:, target_key:, drain_slot:)
|
|
41
|
+
target = delivery_target_for!(target_key)
|
|
42
|
+
slot = drain_slot.to_i
|
|
43
|
+
effective_limit = limit || SearchEngine.config.postgres_outbox.batch_size
|
|
44
|
+
repository = repository_for_slot
|
|
45
|
+
return stale_slot_summary(target.key, slot) unless repository.start_drain_slot!(
|
|
46
|
+
target_key: target.key,
|
|
47
|
+
slot: slot,
|
|
48
|
+
worker_id: worker_id
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
summary = drain_slot_batches(limit: effective_limit, target_key: target.key, drain_slot: slot)
|
|
52
|
+
if summary.delete(:more_work)
|
|
53
|
+
requeued = repository.requeue_drain_slot!(
|
|
54
|
+
target_key: target.key,
|
|
55
|
+
slot: slot,
|
|
56
|
+
worker_id: worker_id
|
|
57
|
+
)
|
|
58
|
+
enqueue_target_continuation(limit: limit, target_key: target.key, drain_slot: slot) if requeued
|
|
59
|
+
else
|
|
60
|
+
repository.release_drain_slot!(target_key: target.key, slot: slot, worker_id: worker_id)
|
|
61
|
+
end
|
|
62
|
+
summary
|
|
63
|
+
rescue StandardError => error
|
|
64
|
+
if repository && target && slot
|
|
65
|
+
repository.release_drain_slot!(target_key: target.key, slot: slot, worker_id: worker_id, error: error)
|
|
66
|
+
end
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def drain_slot_batches(limit:, target_key:, drain_slot:)
|
|
71
|
+
drainer = drainer_for(target_key)
|
|
72
|
+
summary = slot_summary(target_key, drain_slot)
|
|
73
|
+
max_batches = drain_job_max_batches
|
|
74
|
+
batches = 0
|
|
75
|
+
more_work = false
|
|
76
|
+
|
|
77
|
+
while batches < max_batches
|
|
78
|
+
batch_summary = drainer.drain_once(limit: limit)
|
|
79
|
+
batches += 1
|
|
80
|
+
merge_batch_summary!(summary, batch_summary)
|
|
81
|
+
more_work = continue_draining?(batch_summary, limit)
|
|
82
|
+
break unless more_work
|
|
83
|
+
break if runtime_budget_exhausted?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
summary[:batches] = batches
|
|
87
|
+
summary[:more_work] = more_work
|
|
88
|
+
summary
|
|
89
|
+
end
|
|
90
|
+
|
|
34
91
|
def continue_draining?(summary, effective_limit)
|
|
35
92
|
summary[:continue] || summary[:claimed].to_i >= effective_limit.to_i
|
|
36
93
|
end
|
|
@@ -42,18 +99,24 @@ module SearchEngine
|
|
|
42
99
|
end
|
|
43
100
|
|
|
44
101
|
def enqueue_continuation(limit:, target_key:)
|
|
45
|
-
|
|
102
|
+
if target_key
|
|
103
|
+
return enqueue_target_drains(limit: limit) if drain_slots_table_exists?
|
|
104
|
+
|
|
105
|
+
return enqueue_target_continuation(limit: limit, target_key: target_key)
|
|
106
|
+
end
|
|
46
107
|
return self.class.perform_later if limit.nil?
|
|
47
108
|
|
|
48
109
|
self.class.perform_later(limit: limit)
|
|
49
110
|
end
|
|
50
111
|
|
|
51
|
-
def enqueue_target_continuation(limit:, target_key:)
|
|
112
|
+
def enqueue_target_continuation(limit:, target_key:, drain_slot: nil)
|
|
52
113
|
target = delivery_target_for!(target_key)
|
|
53
114
|
job = self.class.set(queue: target.queue_name)
|
|
54
|
-
|
|
115
|
+
kwargs = { target_key: target.key }
|
|
116
|
+
kwargs[:drain_slot] = drain_slot unless drain_slot.nil?
|
|
117
|
+
kwargs[:limit] = limit unless limit.nil?
|
|
55
118
|
|
|
56
|
-
job.perform_later(
|
|
119
|
+
job.perform_later(**kwargs)
|
|
57
120
|
end
|
|
58
121
|
|
|
59
122
|
def delivery_target_for!(target_key)
|
|
@@ -69,6 +132,57 @@ module SearchEngine
|
|
|
69
132
|
raw_targets = configured.respond_to?(:call) ? configured.call : configured
|
|
70
133
|
Array(raw_targets).map { |target| DeliveryTarget.normalize(target) }
|
|
71
134
|
end
|
|
135
|
+
|
|
136
|
+
def repository_for_slot
|
|
137
|
+
SearchEngine::PostgresOutbox::Repository.new
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def drain_slots_table_exists?
|
|
141
|
+
repository_for_slot.drain_slots_table_exists?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def worker_id
|
|
145
|
+
@worker_id ||= "#{Socket.gethostname}:#{Process.pid}:#{job_id}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def slot_summary(target_key, drain_slot)
|
|
149
|
+
{
|
|
150
|
+
claimed: 0,
|
|
151
|
+
processed: 0,
|
|
152
|
+
superseded: 0,
|
|
153
|
+
retryable: 0,
|
|
154
|
+
failed: 0,
|
|
155
|
+
collections: [],
|
|
156
|
+
target_key: target_key,
|
|
157
|
+
drain_slot: drain_slot
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def stale_slot_summary(target_key, drain_slot)
|
|
162
|
+
slot_summary(target_key, drain_slot).merge(stale_slot: true, batches: 0)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def merge_batch_summary!(summary, batch_summary)
|
|
166
|
+
%i[claimed processed superseded retryable failed].each do |key|
|
|
167
|
+
summary[key] += batch_summary[key].to_i
|
|
168
|
+
end
|
|
169
|
+
summary[:collections] |= Array(batch_summary[:collections])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def drain_job_max_batches
|
|
173
|
+
[SearchEngine.config.postgres_outbox.drain_job_max_batches.to_i, 1].max
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def runtime_budget_exhausted?
|
|
177
|
+
max_runtime_s = SearchEngine.config.postgres_outbox.drain_job_max_runtime_s.to_i
|
|
178
|
+
return false unless max_runtime_s.positive?
|
|
179
|
+
|
|
180
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at >= max_runtime_s
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def started_at
|
|
184
|
+
@started_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
185
|
+
end
|
|
72
186
|
end
|
|
73
187
|
end
|
|
74
188
|
end
|
data/lib/search_engine/config.rb
CHANGED
|
@@ -272,6 +272,8 @@ module SearchEngine
|
|
|
272
272
|
attr_accessor :table_name
|
|
273
273
|
# @return [String] database table used by host-managed outbox deliveries
|
|
274
274
|
attr_accessor :delivery_table_name
|
|
275
|
+
# @return [String] database table used by host-managed drain slots
|
|
276
|
+
attr_accessor :drain_slot_table_name
|
|
275
277
|
# @return [String] PostgreSQL notification channel for wakeups
|
|
276
278
|
attr_accessor :channel
|
|
277
279
|
# @return [String] queue name used by host app job dispatch
|
|
@@ -300,11 +302,18 @@ module SearchEngine
|
|
|
300
302
|
attr_accessor :retry_backoff
|
|
301
303
|
# @return [#call] resolver returning configured delivery targets
|
|
302
304
|
attr_accessor :delivery_targets
|
|
305
|
+
# @return [Integer] default maximum concurrent drain jobs per delivery target
|
|
306
|
+
attr_accessor :drain_target_parallelism
|
|
307
|
+
# @return [Integer] maximum drain batches a slot-aware job may run before yielding
|
|
308
|
+
attr_accessor :drain_job_max_batches
|
|
309
|
+
# @return [Integer, nil] optional runtime budget in seconds for slot-aware drain jobs
|
|
310
|
+
attr_accessor :drain_job_max_runtime_s
|
|
303
311
|
|
|
304
312
|
def initialize
|
|
305
313
|
@enabled = false
|
|
306
314
|
@table_name = 'search_engine_outbox_events'
|
|
307
315
|
@delivery_table_name = 'search_engine_outbox_deliveries'
|
|
316
|
+
@drain_slot_table_name = 'search_engine_outbox_drain_slots'
|
|
308
317
|
@channel = 'search_engine_outbox'
|
|
309
318
|
@queue_name = 'search_engine'
|
|
310
319
|
@batch_size = 1000
|
|
@@ -319,6 +328,9 @@ module SearchEngine
|
|
|
319
328
|
@collection_processors = {}
|
|
320
329
|
@retry_backoff = ->(attempt) { [attempt.to_i, 1].max * 5 }
|
|
321
330
|
@delivery_targets = -> { [] }
|
|
331
|
+
@drain_target_parallelism = 1
|
|
332
|
+
@drain_job_max_batches = 1
|
|
333
|
+
@drain_job_max_runtime_s = nil
|
|
322
334
|
end
|
|
323
335
|
end
|
|
324
336
|
|
|
@@ -870,6 +882,7 @@ module SearchEngine
|
|
|
870
882
|
enabled: postgres_outbox.enabled ? true : false,
|
|
871
883
|
table_name: postgres_outbox.table_name,
|
|
872
884
|
delivery_table_name: postgres_outbox.delivery_table_name,
|
|
885
|
+
drain_slot_table_name: postgres_outbox.drain_slot_table_name,
|
|
873
886
|
channel: postgres_outbox.channel,
|
|
874
887
|
queue_name: postgres_outbox.queue_name,
|
|
875
888
|
batch_size: postgres_outbox.batch_size,
|
|
@@ -883,7 +896,10 @@ module SearchEngine
|
|
|
883
896
|
listener_enabled: postgres_outbox.listener_enabled,
|
|
884
897
|
collection_processors: postgres_outbox.collection_processors,
|
|
885
898
|
retry_backoff: postgres_outbox.retry_backoff,
|
|
886
|
-
delivery_targets: postgres_outbox.delivery_targets
|
|
899
|
+
delivery_targets: postgres_outbox.delivery_targets,
|
|
900
|
+
drain_target_parallelism: postgres_outbox.drain_target_parallelism,
|
|
901
|
+
drain_job_max_batches: postgres_outbox.drain_job_max_batches,
|
|
902
|
+
drain_job_max_runtime_s: postgres_outbox.drain_job_max_runtime_s
|
|
887
903
|
}
|
|
888
904
|
end
|
|
889
905
|
|
|
@@ -8,12 +8,16 @@ module SearchEngine
|
|
|
8
8
|
attr_reader :key
|
|
9
9
|
# @return [String] ActiveJob queue name used to process this target
|
|
10
10
|
attr_reader :queue_name
|
|
11
|
+
# @return [Integer] maximum concurrent drain jobs for this target
|
|
12
|
+
attr_reader :parallelism
|
|
11
13
|
|
|
12
14
|
# @param key [String, Symbol] stable target identifier
|
|
13
15
|
# @param queue_name [String, Symbol] queue name for target-specific drain jobs
|
|
14
|
-
|
|
16
|
+
# @param parallelism [Integer, #to_i, nil] optional target concurrency cap
|
|
17
|
+
def initialize(key:, queue_name:, parallelism: nil)
|
|
15
18
|
@key = normalize_value(key, 'key')
|
|
16
19
|
@queue_name = normalize_value(queue_name, 'queue_name')
|
|
20
|
+
@parallelism = normalize_parallelism(parallelism)
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Normalize a configured target into a DeliveryTarget.
|
|
@@ -25,11 +29,16 @@ module SearchEngine
|
|
|
25
29
|
|
|
26
30
|
if value.respond_to?(:to_hash)
|
|
27
31
|
hash = value.to_hash
|
|
28
|
-
return new(
|
|
32
|
+
return new(
|
|
33
|
+
key: fetch_hash_value(hash, :key),
|
|
34
|
+
queue_name: fetch_hash_value(hash, :queue_name),
|
|
35
|
+
parallelism: fetch_hash_value(hash, :parallelism)
|
|
36
|
+
)
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
if value.respond_to?(:key) && value.respond_to?(:queue_name)
|
|
32
|
-
|
|
40
|
+
parallelism = value.parallelism if value.respond_to?(:parallelism)
|
|
41
|
+
return new(key: value.key, queue_name: value.queue_name, parallelism: parallelism)
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
raise ArgumentError, 'delivery target must be a DeliveryTarget, Hash, or target-like object'
|
|
@@ -53,6 +62,11 @@ module SearchEngine
|
|
|
53
62
|
|
|
54
63
|
normalized
|
|
55
64
|
end
|
|
65
|
+
|
|
66
|
+
def normalize_parallelism(value)
|
|
67
|
+
normalized = value.nil? ? SearchEngine.config.postgres_outbox.drain_target_parallelism : value
|
|
68
|
+
[normalized.to_i, 1].max
|
|
69
|
+
end
|
|
56
70
|
end
|
|
57
71
|
end
|
|
58
72
|
end
|
|
@@ -28,6 +28,8 @@ module SearchEngine
|
|
|
28
28
|
return enqueue_legacy(limit: limit) if targets.empty?
|
|
29
29
|
|
|
30
30
|
materialize_deliveries(limit: limit)
|
|
31
|
+
return enqueue_acquired_slots(targets, limit: limit) if repository.drain_slots_table_exists?
|
|
32
|
+
|
|
31
33
|
targets.each { |target| enqueue_target(target, limit: limit) }
|
|
32
34
|
nil
|
|
33
35
|
end
|
|
@@ -55,6 +57,20 @@ module SearchEngine
|
|
|
55
57
|
job.perform_later(target_key: target.key, limit: limit)
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def enqueue_acquired_slots(targets, limit:)
|
|
61
|
+
repository.acquire_drain_slots!(targets: targets).each do |slot|
|
|
62
|
+
enqueue_drain_slot(slot, limit: limit)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def enqueue_drain_slot(slot, limit:)
|
|
68
|
+
job = drain_job.set(queue: slot.fetch(:queue_name))
|
|
69
|
+
kwargs = { target_key: slot.fetch(:target_key), drain_slot: slot.fetch(:slot) }
|
|
70
|
+
kwargs[:limit] = limit unless limit.nil?
|
|
71
|
+
job.perform_later(**kwargs)
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
def delivery_targets
|
|
59
75
|
raw_targets = targets_resolver ? targets_resolver.call : configured_delivery_targets
|
|
60
76
|
Array(raw_targets).map { |target| DeliveryTarget.normalize(target) }
|
|
@@ -99,6 +99,39 @@ module SearchEngine
|
|
|
99
99
|
on_delete: :cascade
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Create the SearchEngine outbox drain slots table.
|
|
103
|
+
#
|
|
104
|
+
# @param table_name [String, Symbol] destination drain slots table name
|
|
105
|
+
# @return [void]
|
|
106
|
+
def create_search_engine_outbox_drain_slots(
|
|
107
|
+
table_name: SearchEngine.config.postgres_outbox.drain_slot_table_name
|
|
108
|
+
)
|
|
109
|
+
create_table table_name do |t|
|
|
110
|
+
t.string :target_key, null: false
|
|
111
|
+
t.integer :slot, null: false
|
|
112
|
+
t.string :queue_name, null: false
|
|
113
|
+
t.string :status, null: false, default: 'idle'
|
|
114
|
+
t.datetime :locked_at
|
|
115
|
+
t.string :locked_by
|
|
116
|
+
t.datetime :enqueued_at
|
|
117
|
+
t.datetime :started_at
|
|
118
|
+
t.datetime :finished_at
|
|
119
|
+
t.text :last_error
|
|
120
|
+
t.timestamps
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
add_index table_name,
|
|
124
|
+
%i[target_key slot],
|
|
125
|
+
name: 'idx_se_outbox_drain_slots_unique',
|
|
126
|
+
unique: true
|
|
127
|
+
add_index table_name,
|
|
128
|
+
%i[target_key status slot],
|
|
129
|
+
name: 'idx_se_outbox_drain_slots_target_status'
|
|
130
|
+
add_index table_name,
|
|
131
|
+
%i[status locked_at],
|
|
132
|
+
name: 'idx_se_outbox_drain_slots_stale'
|
|
133
|
+
end
|
|
134
|
+
|
|
102
135
|
# Create or replace a row-level PostgreSQL trigger that writes outbox events.
|
|
103
136
|
#
|
|
104
137
|
# @param table_name [String, Symbol] source table name
|
|
@@ -128,6 +128,100 @@ module SearchEngine
|
|
|
128
128
|
rows
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
# Check whether the optional drain slot table exists.
|
|
132
|
+
#
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def drain_slots_table_exists?
|
|
135
|
+
if connection.respond_to?(:data_source_exists?)
|
|
136
|
+
connection.data_source_exists?(drain_slot_table_name)
|
|
137
|
+
else
|
|
138
|
+
connection.table_exists?(drain_slot_table_name)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Acquire idle drain slots for configured delivery targets.
|
|
143
|
+
#
|
|
144
|
+
# @param targets [Array<SearchEngine::PostgresOutbox::DeliveryTarget, Hash>]
|
|
145
|
+
# @return [Array<Hash>] acquired slot descriptors
|
|
146
|
+
def acquire_drain_slots!(targets:)
|
|
147
|
+
normalized_targets = Array(targets).map { |target| DeliveryTarget.normalize(target) }
|
|
148
|
+
return [] if normalized_targets.empty?
|
|
149
|
+
|
|
150
|
+
connection.transaction do
|
|
151
|
+
ensure_drain_slots!(normalized_targets)
|
|
152
|
+
reset_stale_drain_slots!(normalized_targets)
|
|
153
|
+
rows = select_rows(acquire_drain_slots_sql(normalized_targets))
|
|
154
|
+
return rows.map { |row| drain_slot_descriptor(row) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Mark an acquired drain slot as processing for the current worker.
|
|
159
|
+
#
|
|
160
|
+
# @param target_key [String, Symbol]
|
|
161
|
+
# @param slot [Integer]
|
|
162
|
+
# @param worker_id [String]
|
|
163
|
+
# @return [Boolean] whether the queued slot was claimed by this worker
|
|
164
|
+
def start_drain_slot!(target_key:, slot:, worker_id:)
|
|
165
|
+
rows = select_rows(<<~SQL)
|
|
166
|
+
UPDATE #{quoted_drain_slot_table}
|
|
167
|
+
SET status = 'processing',
|
|
168
|
+
locked_at = CURRENT_TIMESTAMP,
|
|
169
|
+
locked_by = #{quote(worker_id)},
|
|
170
|
+
started_at = CURRENT_TIMESTAMP,
|
|
171
|
+
updated_at = CURRENT_TIMESTAMP
|
|
172
|
+
WHERE target_key = #{quote(target_key)}
|
|
173
|
+
AND slot = #{slot.to_i}
|
|
174
|
+
AND status = 'queued'
|
|
175
|
+
RETURNING target_key, slot
|
|
176
|
+
SQL
|
|
177
|
+
rows.any?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Requeue a processing drain slot for a follow-up job.
|
|
181
|
+
#
|
|
182
|
+
# @param target_key [String, Symbol]
|
|
183
|
+
# @param slot [Integer]
|
|
184
|
+
# @param worker_id [String]
|
|
185
|
+
# @return [Boolean] whether the current worker still owned and requeued the slot
|
|
186
|
+
def requeue_drain_slot!(target_key:, slot:, worker_id:)
|
|
187
|
+
rows = select_rows(<<~SQL)
|
|
188
|
+
UPDATE #{quoted_drain_slot_table}
|
|
189
|
+
SET status = 'queued',
|
|
190
|
+
locked_at = CURRENT_TIMESTAMP,
|
|
191
|
+
locked_by = NULL,
|
|
192
|
+
enqueued_at = CURRENT_TIMESTAMP,
|
|
193
|
+
updated_at = CURRENT_TIMESTAMP
|
|
194
|
+
WHERE target_key = #{quote(target_key)}
|
|
195
|
+
AND slot = #{slot.to_i}
|
|
196
|
+
AND status = 'processing'
|
|
197
|
+
AND locked_by = #{quote(worker_id)}
|
|
198
|
+
RETURNING target_key, slot
|
|
199
|
+
SQL
|
|
200
|
+
rows.any?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Release a drain slot back to idle.
|
|
204
|
+
#
|
|
205
|
+
# @param target_key [String, Symbol]
|
|
206
|
+
# @param slot [Integer]
|
|
207
|
+
# @param worker_id [String, nil]
|
|
208
|
+
# @param error [Exception, String, nil]
|
|
209
|
+
# @return [void]
|
|
210
|
+
def release_drain_slot!(target_key:, slot:, worker_id: nil, error: nil)
|
|
211
|
+
execute(<<~SQL)
|
|
212
|
+
UPDATE #{quoted_drain_slot_table}
|
|
213
|
+
SET status = 'idle',
|
|
214
|
+
locked_at = NULL,
|
|
215
|
+
locked_by = NULL,
|
|
216
|
+
finished_at = CURRENT_TIMESTAMP,
|
|
217
|
+
last_error = #{quote(error.nil? ? nil : truncate_error(error))},
|
|
218
|
+
updated_at = CURRENT_TIMESTAMP
|
|
219
|
+
WHERE target_key = #{quote(target_key)}
|
|
220
|
+
AND slot = #{slot.to_i}
|
|
221
|
+
#{drain_slot_owner_guard_sql(worker_id)}
|
|
222
|
+
SQL
|
|
223
|
+
end
|
|
224
|
+
|
|
131
225
|
private
|
|
132
226
|
|
|
133
227
|
attr_reader :target_key
|
|
@@ -571,6 +665,98 @@ module SearchEngine
|
|
|
571
665
|
end.join(', ')
|
|
572
666
|
end
|
|
573
667
|
|
|
668
|
+
def ensure_drain_slots!(targets)
|
|
669
|
+
targets.each do |target|
|
|
670
|
+
execute(<<~SQL)
|
|
671
|
+
INSERT INTO #{quoted_drain_slot_table} (
|
|
672
|
+
target_key,
|
|
673
|
+
slot,
|
|
674
|
+
queue_name,
|
|
675
|
+
status,
|
|
676
|
+
created_at,
|
|
677
|
+
updated_at
|
|
678
|
+
)
|
|
679
|
+
SELECT #{quote(target.key)},
|
|
680
|
+
slot,
|
|
681
|
+
#{quote(target.queue_name)},
|
|
682
|
+
'idle',
|
|
683
|
+
CURRENT_TIMESTAMP,
|
|
684
|
+
CURRENT_TIMESTAMP
|
|
685
|
+
FROM generate_series(1, #{target.parallelism}) AS slot
|
|
686
|
+
ON CONFLICT (target_key, slot) DO UPDATE
|
|
687
|
+
SET queue_name = EXCLUDED.queue_name,
|
|
688
|
+
updated_at = #{quoted_drain_slot_table}.updated_at
|
|
689
|
+
SQL
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def reset_stale_drain_slots!(targets)
|
|
694
|
+
execute(<<~SQL)
|
|
695
|
+
UPDATE #{quoted_drain_slot_table}
|
|
696
|
+
SET status = 'idle',
|
|
697
|
+
locked_at = NULL,
|
|
698
|
+
locked_by = NULL,
|
|
699
|
+
last_error = NULL,
|
|
700
|
+
updated_at = CURRENT_TIMESTAMP
|
|
701
|
+
WHERE target_key IN (#{ids_sql(targets.map(&:key))})
|
|
702
|
+
AND status IN ('queued', 'processing')
|
|
703
|
+
AND locked_at < (CURRENT_TIMESTAMP - interval '#{processing_timeout_s} seconds')
|
|
704
|
+
SQL
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def acquire_drain_slots_sql(targets)
|
|
708
|
+
<<~SQL
|
|
709
|
+
WITH target(target_key, queue_name, parallelism) AS (
|
|
710
|
+
VALUES #{drain_slot_target_values_sql(targets)}
|
|
711
|
+
),
|
|
712
|
+
available AS (
|
|
713
|
+
SELECT slots.id
|
|
714
|
+
FROM #{quoted_drain_slot_table} slots
|
|
715
|
+
INNER JOIN target
|
|
716
|
+
ON target.target_key = slots.target_key
|
|
717
|
+
WHERE slots.status = 'idle'
|
|
718
|
+
AND slots.slot <= target.parallelism
|
|
719
|
+
ORDER BY slots.target_key ASC, slots.slot ASC
|
|
720
|
+
FOR UPDATE SKIP LOCKED
|
|
721
|
+
),
|
|
722
|
+
updated AS (
|
|
723
|
+
UPDATE #{quoted_drain_slot_table} slots
|
|
724
|
+
SET status = 'queued',
|
|
725
|
+
locked_at = CURRENT_TIMESTAMP,
|
|
726
|
+
locked_by = NULL,
|
|
727
|
+
enqueued_at = CURRENT_TIMESTAMP,
|
|
728
|
+
last_error = NULL,
|
|
729
|
+
updated_at = CURRENT_TIMESTAMP
|
|
730
|
+
FROM available
|
|
731
|
+
WHERE slots.id = available.id
|
|
732
|
+
RETURNING slots.target_key, slots.slot, slots.queue_name
|
|
733
|
+
)
|
|
734
|
+
SELECT target_key, slot, queue_name
|
|
735
|
+
FROM updated
|
|
736
|
+
ORDER BY target_key ASC, slot ASC
|
|
737
|
+
SQL
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def drain_slot_target_values_sql(targets)
|
|
741
|
+
targets.map do |target|
|
|
742
|
+
"(#{quote(target.key)}, #{quote(target.queue_name)}, #{target.parallelism})"
|
|
743
|
+
end.join(', ')
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def drain_slot_descriptor(row)
|
|
747
|
+
{
|
|
748
|
+
target_key: row_value(row, :target_key).to_s,
|
|
749
|
+
slot: row_value(row, :slot).to_i,
|
|
750
|
+
queue_name: row_value(row, :queue_name).to_s
|
|
751
|
+
}
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def drain_slot_owner_guard_sql(worker_id)
|
|
755
|
+
return '' if worker_id.nil?
|
|
756
|
+
|
|
757
|
+
"AND locked_by = #{quote(worker_id)}"
|
|
758
|
+
end
|
|
759
|
+
|
|
574
760
|
def quoted_table
|
|
575
761
|
connection.quote_table_name(SearchEngine.config.postgres_outbox.table_name)
|
|
576
762
|
end
|
|
@@ -579,6 +765,14 @@ module SearchEngine
|
|
|
579
765
|
connection.quote_table_name(SearchEngine.config.postgres_outbox.delivery_table_name)
|
|
580
766
|
end
|
|
581
767
|
|
|
768
|
+
def quoted_drain_slot_table
|
|
769
|
+
connection.quote_table_name(drain_slot_table_name)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def drain_slot_table_name
|
|
773
|
+
SearchEngine.config.postgres_outbox.drain_slot_table_name
|
|
774
|
+
end
|
|
775
|
+
|
|
582
776
|
def quote(value)
|
|
583
777
|
connection.quote(value)
|
|
584
778
|
end
|