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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +392 -160
- data/lib/solid_jobs/backends/base.rb +58 -0
- data/lib/solid_jobs/backends/redis.rb +80 -0
- data/lib/solid_jobs/backends/solid_engine.rb +288 -0
- data/lib/solid_jobs/backends/worker.rb +166 -0
- data/lib/solid_jobs/blueprint.rb +113 -2
- data/lib/solid_jobs/conductor.rb +115 -0
- data/lib/solid_jobs/publisher.rb +16 -0
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +4 -0
- metadata +5 -1
data/lib/solid_jobs/blueprint.rb
CHANGED
|
@@ -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:
|
|
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]
|
data/lib/solid_jobs/conductor.rb
CHANGED
|
@@ -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 " \
|
data/lib/solid_jobs/publisher.rb
CHANGED
|
@@ -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|
|
data/lib/solid_jobs/version.rb
CHANGED
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.
|
|
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
|