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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +540 -10
- data/docs/reliability.md +5 -0
- 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 +126 -3
- data/lib/solid_jobs/claim.rb +74 -25
- data/lib/solid_jobs/conductor.rb +145 -6
- data/lib/solid_jobs/engine.rb +63 -7
- data/lib/solid_jobs/failure_policy.rb +30 -2
- data/lib/solid_jobs/heartbeat.rb +74 -20
- data/lib/solid_jobs/instrumentation.rb +75 -0
- data/lib/solid_jobs/keyspace.rb +5 -0
- data/lib/solid_jobs/observations.rb +177 -0
- data/lib/solid_jobs/publication_barrier.rb +46 -0
- data/lib/solid_jobs/publisher.rb +53 -8
- data/lib/solid_jobs/recovery.rb +31 -2
- data/lib/solid_jobs/task.rb +35 -0
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +15 -0
- metadata +8 -1
data/lib/solid_jobs/blueprint.rb
CHANGED
|
@@ -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:
|
|
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]
|
data/lib/solid_jobs/claim.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
data/lib/solid_jobs/conductor.rb
CHANGED
|
@@ -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
|
-
@
|
|
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(
|
|
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(
|
|
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.
|
|
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 " \
|