solid-jobs 0.4.2 → 0.6.0

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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "securerandom"
5
+ require "socket"
4
6
 
5
7
  module SolidJobs
6
8
  class Blueprint
@@ -11,9 +13,10 @@ module SolidJobs
11
13
 
12
14
  attr_accessor :concurrency, :discarded_limit, :discarded_retention, :logger,
13
15
  :on_complex_arguments, :poll_interval_average, :shutdown_timeout,
14
- :reliable_fetch, :retry_base_delay, :retry_max_delay
16
+ :reliable_fetch, :retry_base_delay, :retry_max_delay, :instrumenter,
17
+ :backend, :url
15
18
  attr_reader :default_task_options, :error_handlers, :execute_interceptors,
16
- :publish_interceptors, :redis_config
19
+ :publish_interceptors, :redis_config, :identity
17
20
 
18
21
  def initialize(redis: nil, concurrency: 5, channels: ["default"])
19
22
  @redis_config = redis || SolidRedis.config(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
@@ -34,7 +37,103 @@ module SolidJobs
34
37
  @discarded_limit = 10_000
35
38
  @discarded_retention = 180 * 24 * 60 * 60
36
39
  @logger = Logger.new($stdout)
40
+ @identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
41
+ @instrumenter = NullInstrumenter
37
42
  @redis_pool = nil
43
+ @backend = :redis
44
+ @url = nil
45
+ @backends = {}
46
+ @execution_mode = :ractor
47
+ @channel_backends = {}
48
+ @backend_adapters = {}
49
+ end
50
+
51
+ REDIS_ADAPTERS = %i[redis solid_redis].freeze
52
+
53
+ EXECUTION_MODES = %i[ractor thread].freeze
54
+
55
+ attr_reader :backends, :channel_backends, :execution_mode
56
+
57
+ # How non-Redis backend workers run: :ractor (parallel, one client per
58
+ # Ractor) or :thread (GVL-bound). Redis channels always use Ractors.
59
+ def execution_mode=(mode)
60
+ mode = mode.to_sym
61
+ raise ArgumentError, "execution_mode must be :ractor or :thread" unless EXECUTION_MODES.include?(mode)
62
+
63
+ @execution_mode = mode
64
+ end
65
+
66
+ # Named backend definitions: { name => { adapter:, url:, ... } }.
67
+ def backends=(definitions)
68
+ close_adapters
69
+ @backends = definitions.to_h { |name, spec| [name.to_sym, spec.transform_keys(&:to_sym)] }
70
+ end
71
+
72
+ # Per-channel overrides of the default backend: { "bulk" => :solid_engine }.
73
+ def channel_backends=(mapping)
74
+ @channel_backends = mapping.to_h { |channel, name| [channel.to_s, name.to_sym] }
75
+ end
76
+
77
+ def backend_name_for(channel)
78
+ (channel_backends[channel.to_s] || backend).to_sym
79
+ end
80
+
81
+ def redis_backend?(name)
82
+ REDIS_ADAPTERS.include?((backends.dig(name.to_sym, :adapter) || name).to_sym)
83
+ end
84
+
85
+ # True when any channel can leave the default Redis path.
86
+ def routed?
87
+ !redis_backend?(backend) || !channel_backends.empty?
88
+ end
89
+
90
+ def backend_adapter_for(channel)
91
+ backend_adapter(backend_name_for(channel))
92
+ end
93
+
94
+ def backend_adapter(name)
95
+ @backend_adapters[name.to_sym] ||= build_backend(name)
96
+ end
97
+
98
+ # Channels served by Redis workers, and those served by other backends.
99
+ def redis_channel_entries
100
+ channel_entries.select { |channel, _| redis_backend?(backend_name_for(channel)) }
101
+ end
102
+
103
+ def engine_channel_entries
104
+ channel_entries - redis_channel_entries
105
+ end
106
+
107
+ # Adapter for :solid_engine (nil when it is not configured).
108
+ def engine_backend
109
+ name = (backends.keys + [backend, *channel_backends.values]).uniq.find do |candidate|
110
+ !redis_backend?(candidate)
111
+ end
112
+ backend_adapter(name) if name
113
+ end
114
+
115
+ # A fresh, unshared adapter instance (one connection per worker thread).
116
+ def build_backend(name)
117
+ name = name.to_sym
118
+ spec = backends[name] || {}
119
+ adapter = (spec[:adapter] || name).to_sym
120
+ case adapter
121
+ when :redis, :solid_redis
122
+ return Backends::Redis.new(config: self) unless spec[:url]
123
+
124
+ derived = Blueprint.new(redis: SolidRedis.config(url: spec[:url]), concurrency: concurrency)
125
+ derived.logger = logger
126
+ derived.instrumenter = instrumenter
127
+ Backends::Redis.new(config: derived, owned: true)
128
+ when :solid_engine
129
+ options = spec.except(:adapter)
130
+ options[:url] ||= url || ENV.fetch("SOLID_ENGINE_URL") do
131
+ raise ConfigurationError, "SolidEngine backend #{name.inspect} needs a url"
132
+ end
133
+ Backends::SolidEngine.new(**options)
134
+ else
135
+ raise ConfigurationError, "Unknown backend adapter #{adapter.inspect} for #{name.inspect}"
136
+ end
38
137
  end
39
138
 
40
139
  def redis=(configuration)
@@ -107,6 +206,7 @@ module SolidJobs
107
206
  def close
108
207
  @redis_pool&.close
109
208
  @redis_pool = nil
209
+ close_adapters
110
210
  self
111
211
  end
112
212
 
@@ -115,10 +215,14 @@ module SolidJobs
115
215
  end
116
216
 
117
217
  def ractor_snapshot
218
+ unless Ractor.shareable?(instrumenter)
219
+ raise ArgumentError, "SolidJobs instrumenter must be Ractor-shareable"
220
+ end
221
+
118
222
  Utilities.shareable_copy(
119
223
  redis_config: redis_config,
120
224
  concurrency: concurrency,
121
- channels: channel_entries,
225
+ channels: redis_channel_entries,
122
226
  channel_order: channel_order,
123
227
  reliable_fetch: reliable_fetch,
124
228
  default_task_options: default_task_options,
@@ -131,6 +235,13 @@ module SolidJobs
131
235
  retry_max_delay: retry_max_delay,
132
236
  publish_interceptors: publish_interceptors.export,
133
237
  execute_interceptors: execute_interceptors.export,
238
+ identity: identity,
239
+ instrumenter: instrumenter,
240
+ backend: backend,
241
+ url: url,
242
+ backends: backends,
243
+ channel_backends: channel_backends,
244
+ execution_mode: execution_mode,
134
245
  )
135
246
  end
136
247
 
@@ -152,11 +263,23 @@ module SolidJobs
152
263
  config.retry_max_delay = snapshot.fetch(:retry_max_delay)
153
264
  config.publish_interceptors.import(snapshot.fetch(:publish_interceptors))
154
265
  config.execute_interceptors.import(snapshot.fetch(:execute_interceptors))
266
+ config.instance_variable_set(:@identity, snapshot.fetch(:identity))
267
+ config.instrumenter = snapshot.fetch(:instrumenter)
268
+ config.backend = snapshot.fetch(:backend)
269
+ config.url = snapshot.fetch(:url)
270
+ config.backends = snapshot.fetch(:backends)
271
+ config.channel_backends = snapshot.fetch(:channel_backends)
272
+ config.execution_mode = snapshot.fetch(:execution_mode)
155
273
  config
156
274
  end
157
275
 
158
276
  private
159
277
 
278
+ def close_adapters
279
+ @backend_adapters.each_value(&:close)
280
+ @backend_adapters = {}
281
+ end
282
+
160
283
  def normalize_channels(values)
161
284
  Array(values).map do |value|
162
285
  name, weight = value.is_a?(Array) ? value : [value, 1]
@@ -12,9 +12,14 @@ module SolidJobs
12
12
  if not payload then
13
13
  return nil
14
14
  end
15
+ local job = cjson.decode(payload)
16
+ local publication = job["#{PublicationBarrier::FIELD}"]
17
+ if publication and redis.call("exists", ARGV[5] .. publication) == 1 then
18
+ redis.call("rpush", KEYS[1], payload)
19
+ return nil
20
+ end
15
21
 
16
22
  redis.call("lpush", KEYS[2], payload)
17
- local job = cjson.decode(payload)
18
23
  local attempt = redis.call("hincrby", KEYS[4], job["id"], 1)
19
24
  local metadata = {
20
25
  task_id = job["id"],
@@ -28,6 +33,19 @@ module SolidJobs
28
33
  redis.call("hset", KEYS[3], ARGV[3], cjson.encode(metadata))
29
34
  return {payload, attempt}
30
35
  LUA
36
+ POP_READY = <<~LUA.freeze
37
+ local payload = redis.call("rpop", KEYS[1])
38
+ if not payload then
39
+ return nil
40
+ end
41
+ local job = cjson.decode(payload)
42
+ local publication = job["#{PublicationBarrier::FIELD}"]
43
+ if publication and redis.call("exists", ARGV[1] .. publication) == 1 then
44
+ redis.call("rpush", KEYS[1], payload)
45
+ return nil
46
+ end
47
+ return payload
48
+ LUA
31
49
  REGISTER = <<~LUA.freeze
32
50
  local attempt = redis.call("hincrby", KEYS[2], ARGV[1], 1)
33
51
  local metadata = cjson.decode(ARGV[3])
@@ -66,6 +84,7 @@ module SolidJobs
66
84
  return removed
67
85
  LUA
68
86
  RESERVE_SHA = Digest::SHA1.hexdigest(RESERVE).freeze
87
+ POP_READY_SHA = Digest::SHA1.hexdigest(POP_READY).freeze
69
88
  REGISTER_SHA = Digest::SHA1.hexdigest(REGISTER).freeze
70
89
  ACK_SHA = Digest::SHA1.hexdigest(ACK).freeze
71
90
  REQUEUE_SHA = Digest::SHA1.hexdigest(REQUEUE).freeze
@@ -131,6 +150,7 @@ module SolidJobs
131
150
  end
132
151
 
133
152
  def initialize(config, identity: nil, processor_id: nil)
153
+ @config = config
134
154
  @redis_pool = config.redis_pool
135
155
  @channel_order = config.channel_order
136
156
  @reliable = config.reliable_fetch
@@ -167,12 +187,7 @@ module SolidJobs
167
187
  if @claimed_key
168
188
  claim_reliable(channels)
169
189
  else
170
- channels = channels.shuffle if @channel_order == :shuffle
171
- result = @redis_pool.blocking_call(TIMEOUT, "BRPOP", *channels, TIMEOUT)
172
- result && record(
173
- result.fetch(1),
174
- result.fetch(0).delete_prefix("#{Keyspace::PREFIX}:channel:"),
175
- )
190
+ claim_unreliable(channels)
176
191
  end
177
192
  end
178
193
 
@@ -183,10 +198,10 @@ module SolidJobs
183
198
  private
184
199
 
185
200
  def record(payload, channel = nil, claim_token: nil, attempt: nil)
186
- envelope = JSON.parse(payload)
201
+ envelope = PublicationBarrier.strip(JSON.parse(payload))
187
202
  channel ||= envelope.fetch("channel")
188
203
  claim_token, attempt = register(envelope, channel) unless claim_token
189
- ClaimRecord.new(
204
+ claim = ClaimRecord.new(
190
205
  channel: channel,
191
206
  payload: payload,
192
207
  envelope: envelope,
@@ -197,13 +212,22 @@ module SolidJobs
197
212
  claim_token: claim_token,
198
213
  attempt: attempt,
199
214
  )
215
+ Instrumentation.emit(
216
+ @config,
217
+ :reserved,
218
+ envelope,
219
+ reservation_id: claim_token,
220
+ attempt: attempt,
221
+ **Instrumentation.worker_attributes(@processor_id),
222
+ )
223
+ claim
200
224
  end
201
225
 
202
226
  def existing_claim(payload)
203
227
  raw = @redis_pool.call("HGET", @claims_key, @processor_id)
204
228
  if raw
205
229
  metadata = JSON.parse(raw)
206
- envelope = JSON.parse(payload)
230
+ envelope = PublicationBarrier.strip(JSON.parse(payload))
207
231
  if metadata["task_id"] == envelope["id"]
208
232
  return ClaimRecord.new(
209
233
  channel: envelope.fetch("channel"),
@@ -260,22 +284,33 @@ module SolidJobs
260
284
  end
261
285
 
262
286
  def claim_reliable(channels)
263
- if @channel_order == :priority
264
- channels.each do |source|
265
- claim = claim_now(source)
266
- return claim if claim
267
- end
268
- source = channels.first
269
- else
270
- source = next_channel(channels)
287
+ ordered_sources(channels).each do |source|
271
288
  claim = claim_now(source)
272
289
  return claim if claim
273
290
  end
274
- payload = @redis_pool.blocking_call(
275
- TIMEOUT,
276
- "BLMOVE", source, @claimed_key, "RIGHT", "LEFT", TIMEOUT,
277
- )
278
- payload && record(payload, @channel_names.fetch(source))
291
+ sleep(TIMEOUT)
292
+ nil
293
+ end
294
+
295
+ def claim_unreliable(channels)
296
+ ordered_sources(channels).each do |source|
297
+ payload = pop_ready(source)
298
+ return record(payload, @channel_names.fetch(source)) if payload
299
+ end
300
+ sleep(TIMEOUT)
301
+ nil
302
+ end
303
+
304
+ def ordered_sources(channels)
305
+ case @channel_order
306
+ when :priority
307
+ channels.uniq
308
+ when :shuffle
309
+ channels.shuffle.uniq
310
+ else
311
+ preferred = next_channel(channels)
312
+ ([preferred] + channels).uniq
313
+ end
279
314
  end
280
315
 
281
316
  def claim_now(source)
@@ -285,7 +320,7 @@ module SolidJobs
285
320
  @redis_pool.call(
286
321
  "EVALSHA", RESERVE_SHA, 4,
287
322
  source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
288
- claim_token, @identity, @processor_id, claimed_at,
323
+ claim_token, @identity, @processor_id, claimed_at, Keyspace::PUBLICATION_PREFIX,
289
324
  )
290
325
  rescue SolidRedis::CommandError => error
291
326
  raise unless error.message.include?("NOSCRIPT")
@@ -293,7 +328,7 @@ module SolidJobs
293
328
  @redis_pool.call(
294
329
  "EVAL", RESERVE, 4,
295
330
  source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
296
- claim_token, @identity, @processor_id, claimed_at,
331
+ claim_token, @identity, @processor_id, claimed_at, Keyspace::PUBLICATION_PREFIX,
297
332
  )
298
333
  end
299
334
  return unless result
@@ -307,6 +342,20 @@ module SolidJobs
307
342
  )
308
343
  end
309
344
 
345
+ def pop_ready(source)
346
+ @redis_pool.call(
347
+ "EVALSHA", POP_READY_SHA, 1,
348
+ source, Keyspace::PUBLICATION_PREFIX,
349
+ )
350
+ rescue SolidRedis::CommandError => error
351
+ raise unless error.message.include?("NOSCRIPT")
352
+
353
+ @redis_pool.call(
354
+ "EVAL", POP_READY, 1,
355
+ source, Keyspace::PUBLICATION_PREFIX,
356
+ )
357
+ end
358
+
310
359
  def refresh_paused
311
360
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
312
361
  return if now < @paused_refresh_at
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "socket"
5
3
  require "timeout"
6
4
 
7
5
  module SolidJobs
@@ -20,16 +18,23 @@ module SolidJobs
20
18
  @scheduler = nil
21
19
  @heartbeat = nil
22
20
  @started = false
23
- @identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
21
+ @backend_threads = []
22
+ @backend_workers = []
23
+ @backend_ractors = []
24
+ @backend_stop = false
25
+ @backend_paused = false
26
+ @identity = config.identity
24
27
  @started_at = Time.now.to_f
25
28
  end
26
29
 
27
30
  def start
28
31
  raise Error, "Conductor is already running" if @started
29
32
 
33
+ return start_backend_only if config.redis_channel_entries.empty?
34
+
30
35
  warn_ruby34_multi_ractor
31
36
  snapshot = Utilities.shareable_copy(
32
- config.ractor_snapshot.merge(identity: identity, started_at: @started_at),
37
+ config.ractor_snapshot.merge(started_at: @started_at),
33
38
  )
34
39
  Recovery.call(config: config)
35
40
  barrier = StartupBarrier.new(config.concurrency + 2)
@@ -60,6 +65,7 @@ module SolidJobs
60
65
  quiet = false
61
66
  stopping = false
62
67
  work = {}
68
+ redis = {}
63
69
  processed = failed = 0
64
70
  next_beat = 0.0
65
71
  until stopping
@@ -68,6 +74,7 @@ module SolidJobs
68
74
  case (message = messages.pop(true))
69
75
  when :quiet
70
76
  quiet = true
77
+ heartbeat.observe(quiet: true, work: work, redis: redis)
71
78
  when :stop
72
79
  stopping = true
73
80
  else
@@ -75,11 +82,21 @@ module SolidJobs
75
82
  case event
76
83
  when :work
77
84
  work[arguments[0].to_s] = arguments[1]
85
+ heartbeat.observe_ractor(
86
+ arguments[0],
87
+ arguments[1],
88
+ job_id: arguments[2],
89
+ quiet: quiet,
90
+ )
78
91
  when :done
79
92
  work.delete(arguments[0].to_s)
93
+ heartbeat.observe_ractor(arguments[0], nil, quiet: quiet)
80
94
  when :stats
81
95
  processed += arguments[0]
82
96
  failed += arguments[1]
97
+ when :redis
98
+ redis[arguments[0].to_s] = arguments[1]
99
+ heartbeat.observe_redis(arguments[0], arguments[1])
83
100
  end
84
101
  end
85
102
  end
@@ -89,7 +106,13 @@ module SolidJobs
89
106
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
90
107
  if now >= next_beat
91
108
  begin
92
- heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
109
+ heartbeat.beat(
110
+ quiet: quiet,
111
+ work: work,
112
+ processed: processed,
113
+ failed: failed,
114
+ redis: redis,
115
+ )
93
116
  processed = failed = 0
94
117
  rescue SolidRedis::ConnectionError => error
95
118
  local_config.logger.warn("Redis heartbeat failed, retrying: #{error.class}: #{error.message}")
@@ -99,7 +122,15 @@ module SolidJobs
99
122
  sleep 0.05 unless stopping
100
123
  end
101
124
  begin
102
- heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
125
+ heartbeat.observe(quiet: quiet, work: work, redis: redis, phase: :stopping)
126
+ heartbeat.beat(
127
+ quiet: quiet,
128
+ work: work,
129
+ processed: processed,
130
+ failed: failed,
131
+ redis: redis,
132
+ phase: :stopped,
133
+ )
103
134
  heartbeat.cleanup
104
135
  rescue SolidRedis::ConnectionError => error
105
136
  local_config.logger.warn("Redis heartbeat cleanup failed: #{error.class}: #{error.message}")
@@ -181,6 +212,7 @@ module SolidJobs
181
212
  end
182
213
  barrier.register!(@scheduler, scheduler_ready)
183
214
  barrier.run!
215
+ start_backend_workers
184
216
  @started = true
185
217
  config.fire(:startup, self)
186
218
  self
@@ -194,6 +226,8 @@ module SolidJobs
194
226
  def quiet
195
227
  return self unless @started
196
228
 
229
+ @backend_paused = true
230
+ @backend_ractors.each { |ractor| ractor.send(:quiet) }
197
231
  @ractors.each { |ractor| ractor.send(:quiet) }
198
232
  @heartbeat&.send(:quiet)
199
233
  config.fire(:quiet, self)
@@ -203,6 +237,7 @@ module SolidJobs
203
237
  def stop
204
238
  return [] unless @started
205
239
 
240
+ @backend_stop = true
206
241
  @ractors.each { |ractor| ractor.send(:stop) }
207
242
  @scheduler&.send(:stop)
208
243
  results = @ractors.each_with_index.map do |ractor, index|
@@ -211,6 +246,7 @@ module SolidJobs
211
246
  await_termination(@scheduler, "scheduler") if @scheduler
212
247
  @heartbeat&.send(:stop)
213
248
  await_termination(@heartbeat, "heartbeat") if @heartbeat
249
+ results.concat(stop_backend_workers)
214
250
  @ractors = []
215
251
  @scheduler = nil
216
252
  @heartbeat = nil
@@ -225,6 +261,109 @@ module SolidJobs
225
261
 
226
262
  private
227
263
 
264
+ # Channels routed to a non-Redis backend run in Ractors (one client each)
265
+ # or, with execution_mode = :thread, in threads of this process.
266
+ def start_backend_workers
267
+ channels = config.engine_channel_entries
268
+ return if channels.empty?
269
+
270
+ @backend_stop = false
271
+ @backend_paused = false
272
+ groups = channels.group_by { |channel, _| config.backend_name_for(channel) }
273
+ if config.execution_mode == :ractor
274
+ start_backend_ractors(groups)
275
+ else
276
+ start_backend_threads(groups)
277
+ end
278
+ end
279
+
280
+ def start_backend_threads(groups)
281
+ groups.each do |name, scoped|
282
+ config.concurrency.times do |index|
283
+ worker = Backends::Worker.new(config: config, backend_name: name, channels: scoped, processor_id: index)
284
+ @backend_workers << worker
285
+ @backend_threads << Thread.new do
286
+ worker.run(stop: -> { @backend_stop }, paused: -> { @backend_paused })
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ def start_backend_ractors(groups)
293
+ snapshot = Utilities.shareable_copy(config.ractor_snapshot.merge(started_at: @started_at))
294
+ barrier = StartupBarrier.new(groups.size * config.concurrency)
295
+ groups.each do |name, scoped|
296
+ shared_channels = Utilities.shareable_copy(scoped)
297
+ config.concurrency.times do |index|
298
+ ready = barrier.channel
299
+ ractor = Ractor.new(snapshot, name, shared_channels, index, ready) do |settings, backend, entries, id, ready_port|
300
+ local_config = worker = nil
301
+ begin
302
+ started = SolidJobs::StartupBarrier.boot(ready_port) do
303
+ local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
304
+ SolidJobs.use_config(local_config)
305
+ worker = SolidJobs::Backends::Worker.new(
306
+ config: local_config, backend_name: backend, channels: entries, processor_id: id,
307
+ )
308
+ end
309
+ next :aborted unless started
310
+
311
+ control = ::Queue.new
312
+ Thread.new do
313
+ loop do
314
+ command = Ractor.receive
315
+ control << command
316
+ break if command == :stop
317
+ end
318
+ end
319
+ state = {stop: false, paused: false}
320
+ drain = lambda do
321
+ until control.empty?
322
+ case control.pop(true)
323
+ when :stop then state[:stop] = true
324
+ when :quiet then state[:paused] = true
325
+ end
326
+ end
327
+ end
328
+ worker.run(stop: -> { drain.call; state[:stop] }, paused: -> { state[:paused] })
329
+ {processed: worker.processed, failed: worker.failed}
330
+ ensure
331
+ local_config&.close
332
+ end
333
+ end
334
+ barrier.register!(ractor, ready)
335
+ @backend_ractors << ractor
336
+ end
337
+ end
338
+ barrier.run!
339
+ rescue StandardError
340
+ barrier&.abort!
341
+ @backend_ractors = []
342
+ raise
343
+ end
344
+
345
+ # Without Redis channels no Ractor, heartbeat or Redis scheduler is needed.
346
+ def start_backend_only
347
+ start_backend_workers
348
+ @started = true
349
+ config.fire(:startup, self)
350
+ self
351
+ end
352
+
353
+ def stop_backend_workers
354
+ @backend_ractors.each { |ractor| ractor.send(:stop) }
355
+ ractor_results = @backend_ractors.each_with_index.map do |ractor, index|
356
+ await_termination(ractor, "backend worker #{index}")
357
+ end
358
+ @backend_ractors = []
359
+ deadline = config.shutdown_timeout + @stop_grace
360
+ @backend_threads.each { |thread| thread.join(deadline) || thread.kill }
361
+ results = @backend_workers.map { |worker| {processed: worker.processed, failed: worker.failed} }
362
+ @backend_threads = []
363
+ @backend_workers = []
364
+ ractor_results.compact + results
365
+ end
366
+
228
367
  RUBY34_MULTI_RACTOR_WARNING =
229
368
  "SolidJobs: Ruby %s can deadlock the whole VM on a Ractor GC barrier " \
230
369
  "under multi-Engine load (see docs/reliability.md). Run multi-Ractor " \