solid-jobs 0.5.0 → 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.
@@ -13,7 +13,8 @@ module SolidJobs
13
13
 
14
14
  attr_accessor :concurrency, :discarded_limit, :discarded_retention, :logger,
15
15
  :on_complex_arguments, :poll_interval_average, :shutdown_timeout,
16
- :reliable_fetch, :retry_base_delay, :retry_max_delay, :instrumenter
16
+ :reliable_fetch, :retry_base_delay, :retry_max_delay, :instrumenter,
17
+ :backend, :url
17
18
  attr_reader :default_task_options, :error_handlers, :execute_interceptors,
18
19
  :publish_interceptors, :redis_config, :identity
19
20
 
@@ -39,6 +40,100 @@ module SolidJobs
39
40
  @identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
40
41
  @instrumenter = NullInstrumenter
41
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
42
137
  end
43
138
 
44
139
  def redis=(configuration)
@@ -111,6 +206,7 @@ module SolidJobs
111
206
  def close
112
207
  @redis_pool&.close
113
208
  @redis_pool = nil
209
+ close_adapters
114
210
  self
115
211
  end
116
212
 
@@ -126,7 +222,7 @@ module SolidJobs
126
222
  Utilities.shareable_copy(
127
223
  redis_config: redis_config,
128
224
  concurrency: concurrency,
129
- channels: channel_entries,
225
+ channels: redis_channel_entries,
130
226
  channel_order: channel_order,
131
227
  reliable_fetch: reliable_fetch,
132
228
  default_task_options: default_task_options,
@@ -141,6 +237,11 @@ module SolidJobs
141
237
  execute_interceptors: execute_interceptors.export,
142
238
  identity: identity,
143
239
  instrumenter: instrumenter,
240
+ backend: backend,
241
+ url: url,
242
+ backends: backends,
243
+ channel_backends: channel_backends,
244
+ execution_mode: execution_mode,
144
245
  )
145
246
  end
146
247
 
@@ -164,11 +265,21 @@ module SolidJobs
164
265
  config.execute_interceptors.import(snapshot.fetch(:execute_interceptors))
165
266
  config.instance_variable_set(:@identity, snapshot.fetch(:identity))
166
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)
167
273
  config
168
274
  end
169
275
 
170
276
  private
171
277
 
278
+ def close_adapters
279
+ @backend_adapters.each_value(&:close)
280
+ @backend_adapters = {}
281
+ end
282
+
172
283
  def normalize_channels(values)
173
284
  Array(values).map do |value|
174
285
  name, weight = value.is_a?(Array) ? value : [value, 1]
@@ -18,6 +18,11 @@ module SolidJobs
18
18
  @scheduler = nil
19
19
  @heartbeat = nil
20
20
  @started = false
21
+ @backend_threads = []
22
+ @backend_workers = []
23
+ @backend_ractors = []
24
+ @backend_stop = false
25
+ @backend_paused = false
21
26
  @identity = config.identity
22
27
  @started_at = Time.now.to_f
23
28
  end
@@ -25,6 +30,8 @@ module SolidJobs
25
30
  def start
26
31
  raise Error, "Conductor is already running" if @started
27
32
 
33
+ return start_backend_only if config.redis_channel_entries.empty?
34
+
28
35
  warn_ruby34_multi_ractor
29
36
  snapshot = Utilities.shareable_copy(
30
37
  config.ractor_snapshot.merge(started_at: @started_at),
@@ -205,6 +212,7 @@ module SolidJobs
205
212
  end
206
213
  barrier.register!(@scheduler, scheduler_ready)
207
214
  barrier.run!
215
+ start_backend_workers
208
216
  @started = true
209
217
  config.fire(:startup, self)
210
218
  self
@@ -218,6 +226,8 @@ module SolidJobs
218
226
  def quiet
219
227
  return self unless @started
220
228
 
229
+ @backend_paused = true
230
+ @backend_ractors.each { |ractor| ractor.send(:quiet) }
221
231
  @ractors.each { |ractor| ractor.send(:quiet) }
222
232
  @heartbeat&.send(:quiet)
223
233
  config.fire(:quiet, self)
@@ -227,6 +237,7 @@ module SolidJobs
227
237
  def stop
228
238
  return [] unless @started
229
239
 
240
+ @backend_stop = true
230
241
  @ractors.each { |ractor| ractor.send(:stop) }
231
242
  @scheduler&.send(:stop)
232
243
  results = @ractors.each_with_index.map do |ractor, index|
@@ -235,6 +246,7 @@ module SolidJobs
235
246
  await_termination(@scheduler, "scheduler") if @scheduler
236
247
  @heartbeat&.send(:stop)
237
248
  await_termination(@heartbeat, "heartbeat") if @heartbeat
249
+ results.concat(stop_backend_workers)
238
250
  @ractors = []
239
251
  @scheduler = nil
240
252
  @heartbeat = nil
@@ -249,6 +261,109 @@ module SolidJobs
249
261
 
250
262
  private
251
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
+
252
367
  RUBY34_MULTI_RACTOR_WARNING =
253
368
  "SolidJobs: Ruby %s can deadlock the whole VM on a Ractor GC barrier " \
254
369
  "under multi-Engine load (see docs/reliability.md). Run multi-Ractor " \
@@ -103,6 +103,22 @@ module SolidJobs
103
103
  end
104
104
  end
105
105
 
106
+ return persist_redis(envelopes) unless @config.routed?
107
+
108
+ envelopes.group_by { |envelope| @config.backend_name_for(envelope.fetch("channel")) }
109
+ .flat_map do |name, group|
110
+ adapter = @config.backend_adapter(name)
111
+ if adapter.is_a?(Backends::Redis) && adapter.config.equal?(@config)
112
+ persist_redis(group)
113
+ else
114
+ adapter.enqueue_many(group.each { |envelope| envelope["queued_ms"] = Utilities.realtime_milliseconds })
115
+ end
116
+ end
117
+ end
118
+
119
+ def persist_redis(envelopes)
120
+ return [] if envelopes.empty?
121
+
106
122
  planned, ready = envelopes.partition { |envelope| envelope["run_at"] }
107
123
  barriers = []
108
124
  @config.redis_pool.pipelined do |pipeline|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidJobs
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/solid_jobs.rb CHANGED
@@ -13,7 +13,10 @@ require_relative "solid_jobs/startup_barrier"
13
13
  require_relative "solid_jobs/interceptor_registry"
14
14
  require_relative "solid_jobs/instrumentation"
15
15
  require_relative "solid_jobs/publication_barrier"
16
+ require_relative "solid_jobs/backends/base"
17
+ require_relative "solid_jobs/backends/solid_engine"
16
18
  require_relative "solid_jobs/blueprint"
19
+ require_relative "solid_jobs/backends/redis"
17
20
  require_relative "solid_jobs/publisher"
18
21
  require_relative "solid_jobs/lab"
19
22
  require_relative "solid_jobs/task"
@@ -26,6 +29,7 @@ require_relative "solid_jobs/observations"
26
29
  require_relative "solid_jobs/heartbeat"
27
30
  require_relative "solid_jobs/recovery"
28
31
  require_relative "solid_jobs/integrity_check"
32
+ require_relative "solid_jobs/backends/worker"
29
33
  require_relative "solid_jobs/conductor"
30
34
  require_relative "solid_jobs/catalog"
31
35
  require_relative "solid_jobs/rails_adapter" if defined?(::ActiveJob::Base)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid-jobs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Vandenbogaerde
@@ -119,6 +119,10 @@ files:
119
119
  - exe/solid-jobs
120
120
  - lib/active_job/queue_adapters/solid_jobs_adapter.rb
121
121
  - lib/solid_jobs.rb
122
+ - lib/solid_jobs/backends/base.rb
123
+ - lib/solid_jobs/backends/redis.rb
124
+ - lib/solid_jobs/backends/solid_engine.rb
125
+ - lib/solid_jobs/backends/worker.rb
122
126
  - lib/solid_jobs/blueprint.rb
123
127
  - lib/solid_jobs/catalog.rb
124
128
  - lib/solid_jobs/claim.rb