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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 680a9c687c571328d2ed1e521cb93b9a2d1962ec53bbeb7b315042015a150506
4
- data.tar.gz: 64250ee42b7643fcdc3979a1e10c05d3d22bfcda11cf282dc1635566ca0f63e4
3
+ metadata.gz: 68b92138a4716df3c14da84a5f66b26f30bbdcb6afcb8d54f19d953c6652d1ed
4
+ data.tar.gz: 7f21c4f3ea8216ad390494f9ca813a3829b80b15b8948f3ea5523f566d101368
5
5
  SHA512:
6
- metadata.gz: 5f6a796bf120e04de8c2a55e4cf662b6e83b5f4a38b2e76315e0f8c79780d383d8f3673b65de5b1218ebfd36388f5dc47a08d385428a824ad26bedaddd09f4b8
7
- data.tar.gz: 1837a2de35e82c7f7e7878eb39a6844d7b96d3767509a0c3829cc62fd12a126ff4f53d6284475c464e3db6c8395444065ac308d1ef673361f83ed33f97ead872
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. 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
+ 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
- return enqueue_target_continuation(limit: limit, target_key: target_key) unless target_key.nil?
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
- return job.perform_later(target_key: target.key) if limit.nil?
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(target_key: target.key, limit: limit)
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
@@ -6,5 +6,6 @@ class CreateSearchEngineOutboxEvents < ActiveRecord::Migration[<%= ActiveRecord:
6
6
  def change
7
7
  create_search_engine_outbox_events
8
8
  create_search_engine_outbox_deliveries
9
+ create_search_engine_outbox_drain_slots
9
10
  end
10
11
  end
@@ -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
- def initialize(key:, queue_name:)
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(key: fetch_hash_value(hash, :key), queue_name: fetch_hash_value(hash, :queue_name))
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
- return new(key: value.key, queue_name: value.queue_name)
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
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.8.13'
6
+ VERSION = '30.1.8.14'
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.13
4
+ version: 30.1.8.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda