solid-jobs 0.1.3 → 0.4.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 +73 -4
- data/README.md +149 -118
- data/docs/reliability.md +73 -91
- data/exe/solid-jobs +2 -3
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +7 -8
- data/lib/solid_jobs/{config.rb → blueprint.rb} +51 -51
- data/lib/solid_jobs/catalog.rb +427 -0
- data/lib/solid_jobs/claim.rb +315 -0
- data/lib/solid_jobs/{server.rb → conductor.rb} +10 -10
- data/lib/solid_jobs/{cli.rb → console.rb} +12 -9
- data/lib/solid_jobs/{processor.rb → engine.rb} +33 -33
- data/lib/solid_jobs/errors.rb +1 -1
- data/lib/solid_jobs/executor.rb +34 -0
- data/lib/solid_jobs/failure_policy.rb +98 -0
- data/lib/solid_jobs/heartbeat.rb +15 -13
- data/lib/solid_jobs/integrity_check.rb +39 -36
- data/lib/solid_jobs/interceptor_registry.rb +57 -0
- data/lib/solid_jobs/keyspace.rb +42 -0
- data/lib/solid_jobs/{testing.rb → lab.rb} +19 -19
- data/lib/solid_jobs/publisher.rb +186 -0
- data/lib/solid_jobs/rails_adapter.rb +13 -0
- data/lib/solid_jobs/{rails.rb → railtie.rb} +1 -2
- data/lib/solid_jobs/recovery.rb +8 -7
- data/lib/solid_jobs/task.rb +147 -0
- data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
- data/lib/solid_jobs/utilities.rb +5 -11
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +28 -27
- metadata +19 -18
- data/lib/solid_jobs/active_job.rb +0 -16
- data/lib/solid_jobs/api.rb +0 -457
- data/lib/solid_jobs/client.rb +0 -202
- data/lib/solid_jobs/fetch.rb +0 -310
- data/lib/solid_jobs/job.rb +0 -156
- data/lib/solid_jobs/middleware/chain.rb +0 -104
- data/lib/solid_jobs/retry_service.rb +0 -100
- data/lib/solid_jobs/worker.rb +0 -35
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "digest/sha1"
|
|
6
|
+
|
|
7
|
+
module SolidJobs
|
|
8
|
+
class Claim
|
|
9
|
+
TIMEOUT = 0.25
|
|
10
|
+
RESERVE = <<~LUA.freeze
|
|
11
|
+
local payload = redis.call("rpop", KEYS[1])
|
|
12
|
+
if not payload then
|
|
13
|
+
return nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
redis.call("lpush", KEYS[2], payload)
|
|
17
|
+
local job = cjson.decode(payload)
|
|
18
|
+
local attempt = redis.call("hincrby", KEYS[4], job["id"], 1)
|
|
19
|
+
local metadata = {
|
|
20
|
+
task_id = job["id"],
|
|
21
|
+
claim_token = ARGV[1],
|
|
22
|
+
node_id = ARGV[2],
|
|
23
|
+
executor_id = ARGV[3],
|
|
24
|
+
channel = job["channel"],
|
|
25
|
+
claimed_at = tonumber(ARGV[4]),
|
|
26
|
+
attempt = attempt
|
|
27
|
+
}
|
|
28
|
+
redis.call("hset", KEYS[3], ARGV[3], cjson.encode(metadata))
|
|
29
|
+
return {payload, attempt}
|
|
30
|
+
LUA
|
|
31
|
+
REGISTER = <<~LUA.freeze
|
|
32
|
+
local attempt = redis.call("hincrby", KEYS[2], ARGV[1], 1)
|
|
33
|
+
local metadata = cjson.decode(ARGV[3])
|
|
34
|
+
metadata["attempt"] = attempt
|
|
35
|
+
redis.call("hset", KEYS[1], ARGV[2], cjson.encode(metadata))
|
|
36
|
+
return attempt
|
|
37
|
+
LUA
|
|
38
|
+
ACK = <<~LUA.freeze
|
|
39
|
+
local metadata = redis.call("hget", KEYS[2], ARGV[2])
|
|
40
|
+
if not metadata then
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
if cjson.decode(metadata)["claim_token"] ~= ARGV[3] then
|
|
44
|
+
return 0
|
|
45
|
+
end
|
|
46
|
+
local removed = redis.call("lrem", KEYS[1], 1, ARGV[1])
|
|
47
|
+
redis.call("hdel", KEYS[2], ARGV[2])
|
|
48
|
+
if removed > 0 then
|
|
49
|
+
redis.call("hdel", KEYS[3], ARGV[4])
|
|
50
|
+
end
|
|
51
|
+
return removed
|
|
52
|
+
LUA
|
|
53
|
+
REQUEUE = <<~LUA.freeze
|
|
54
|
+
local metadata = redis.call("hget", KEYS[2], ARGV[2])
|
|
55
|
+
if not metadata then
|
|
56
|
+
return 0
|
|
57
|
+
end
|
|
58
|
+
if cjson.decode(metadata)["claim_token"] ~= ARGV[3] then
|
|
59
|
+
return 0
|
|
60
|
+
end
|
|
61
|
+
local removed = redis.call("lrem", KEYS[1], 1, ARGV[1])
|
|
62
|
+
if removed > 0 then
|
|
63
|
+
redis.call("rpush", KEYS[3], ARGV[1])
|
|
64
|
+
end
|
|
65
|
+
redis.call("hdel", KEYS[2], ARGV[2])
|
|
66
|
+
return removed
|
|
67
|
+
LUA
|
|
68
|
+
RESERVE_SHA = Digest::SHA1.hexdigest(RESERVE).freeze
|
|
69
|
+
REGISTER_SHA = Digest::SHA1.hexdigest(REGISTER).freeze
|
|
70
|
+
ACK_SHA = Digest::SHA1.hexdigest(ACK).freeze
|
|
71
|
+
REQUEUE_SHA = Digest::SHA1.hexdigest(REQUEUE).freeze
|
|
72
|
+
|
|
73
|
+
ClaimRecord = Struct.new(
|
|
74
|
+
:channel,
|
|
75
|
+
:payload,
|
|
76
|
+
:envelope,
|
|
77
|
+
:redis_pool,
|
|
78
|
+
:claimed_key,
|
|
79
|
+
:claims_key,
|
|
80
|
+
:executor_field,
|
|
81
|
+
:claim_token,
|
|
82
|
+
:attempt,
|
|
83
|
+
keyword_init: true,
|
|
84
|
+
) do
|
|
85
|
+
def complete
|
|
86
|
+
if claimed_key
|
|
87
|
+
removed = begin
|
|
88
|
+
redis_pool.call(
|
|
89
|
+
"EVALSHA", Claim::ACK_SHA, 3,
|
|
90
|
+
claimed_key, claims_key, Keyspace::ATTEMPTS,
|
|
91
|
+
payload, executor_field, claim_token, envelope.fetch("id"),
|
|
92
|
+
)
|
|
93
|
+
rescue SolidRedis::CommandError => error
|
|
94
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
95
|
+
|
|
96
|
+
redis_pool.call(
|
|
97
|
+
"EVAL", Claim::ACK, 3,
|
|
98
|
+
claimed_key, claims_key, Keyspace::ATTEMPTS,
|
|
99
|
+
payload, executor_field, claim_token, envelope.fetch("id"),
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
return Integer(removed) == 1
|
|
103
|
+
end
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def requeue
|
|
108
|
+
if claimed_key
|
|
109
|
+
destination = Keyspace.channel(channel)
|
|
110
|
+
removed = begin
|
|
111
|
+
redis_pool.call(
|
|
112
|
+
"EVALSHA", Claim::REQUEUE_SHA, 3,
|
|
113
|
+
claimed_key, claims_key, destination,
|
|
114
|
+
payload, executor_field, claim_token,
|
|
115
|
+
)
|
|
116
|
+
rescue SolidRedis::CommandError => error
|
|
117
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
118
|
+
|
|
119
|
+
redis_pool.call(
|
|
120
|
+
"EVAL", Claim::REQUEUE, 3,
|
|
121
|
+
claimed_key, claims_key, destination,
|
|
122
|
+
payload, executor_field, claim_token,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
return Integer(removed) == 1
|
|
126
|
+
else
|
|
127
|
+
redis_pool.call("RPUSH", Keyspace.channel(channel), payload)
|
|
128
|
+
end
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def initialize(config, identity: nil, processor_id: nil)
|
|
134
|
+
@redis_pool = config.redis_pool
|
|
135
|
+
@channel_order = config.channel_order
|
|
136
|
+
@reliable = config.reliable_fetch
|
|
137
|
+
@channels = config.channel_entries.flat_map do |name, weight|
|
|
138
|
+
Array.new(weight, Keyspace.channel(name))
|
|
139
|
+
end.freeze
|
|
140
|
+
@claimed_key = Keyspace.claimed(identity, processor_id) if @reliable && identity
|
|
141
|
+
@claims_key = Keyspace.claims(identity) if @claimed_key
|
|
142
|
+
@identity = identity
|
|
143
|
+
@processor_id = processor_id
|
|
144
|
+
@paused = []
|
|
145
|
+
@paused_refresh_at = 0.0
|
|
146
|
+
@channel_index = 0
|
|
147
|
+
@check_claimed = !!@claimed_key
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def next
|
|
151
|
+
if @check_claimed
|
|
152
|
+
@check_claimed = false
|
|
153
|
+
if (payload = @redis_pool.call("LINDEX", @claimed_key, -1))
|
|
154
|
+
return existing_claim(payload)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
refresh_paused
|
|
159
|
+
channels = @channels.reject do |key|
|
|
160
|
+
@paused.include?(key.delete_prefix("#{Keyspace::PREFIX}:channel:"))
|
|
161
|
+
end
|
|
162
|
+
return sleep(0.05) if channels.empty?
|
|
163
|
+
|
|
164
|
+
if @claimed_key
|
|
165
|
+
claim_reliable(channels)
|
|
166
|
+
else
|
|
167
|
+
channels = channels.shuffle if @channel_order == :shuffle
|
|
168
|
+
result = @redis_pool.blocking_call(TIMEOUT, "BRPOP", *channels, TIMEOUT)
|
|
169
|
+
result && record(
|
|
170
|
+
result.fetch(1),
|
|
171
|
+
result.fetch(0).delete_prefix("#{Keyspace::PREFIX}:channel:"),
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def connection_failed!
|
|
177
|
+
@check_claimed = !!@claimed_key
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def record(payload, channel = nil, claim_token: nil, attempt: nil)
|
|
183
|
+
envelope = JSON.parse(payload)
|
|
184
|
+
channel ||= envelope.fetch("channel")
|
|
185
|
+
claim_token, attempt = register(envelope, channel) unless claim_token
|
|
186
|
+
ClaimRecord.new(
|
|
187
|
+
channel: channel,
|
|
188
|
+
payload: payload,
|
|
189
|
+
envelope: envelope,
|
|
190
|
+
redis_pool: @redis_pool,
|
|
191
|
+
claimed_key: @claimed_key,
|
|
192
|
+
claims_key: @claims_key,
|
|
193
|
+
executor_field: @processor_id.to_s,
|
|
194
|
+
claim_token: claim_token,
|
|
195
|
+
attempt: attempt,
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def existing_claim(payload)
|
|
200
|
+
raw = @redis_pool.call("HGET", @claims_key, @processor_id)
|
|
201
|
+
if raw
|
|
202
|
+
metadata = JSON.parse(raw)
|
|
203
|
+
envelope = JSON.parse(payload)
|
|
204
|
+
if metadata["task_id"] == envelope["id"]
|
|
205
|
+
return ClaimRecord.new(
|
|
206
|
+
channel: envelope.fetch("channel"),
|
|
207
|
+
payload: payload,
|
|
208
|
+
envelope: envelope,
|
|
209
|
+
redis_pool: @redis_pool,
|
|
210
|
+
claimed_key: @claimed_key,
|
|
211
|
+
claims_key: @claims_key,
|
|
212
|
+
executor_field: @processor_id.to_s,
|
|
213
|
+
claim_token: metadata.fetch("claim_token"),
|
|
214
|
+
attempt: Integer(metadata.fetch("attempt")),
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
record(payload)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def register(envelope, channel)
|
|
222
|
+
return [nil, nil] unless @claimed_key
|
|
223
|
+
|
|
224
|
+
claim_token = SecureRandom.uuid
|
|
225
|
+
metadata = JSON.generate(
|
|
226
|
+
"task_id" => envelope["id"],
|
|
227
|
+
"claim_token" => claim_token,
|
|
228
|
+
"node_id" => @identity,
|
|
229
|
+
"executor_id" => @processor_id,
|
|
230
|
+
"channel" => channel,
|
|
231
|
+
"claimed_at" => Time.now.to_f,
|
|
232
|
+
)
|
|
233
|
+
attempt = begin
|
|
234
|
+
@redis_pool.call(
|
|
235
|
+
"EVALSHA", REGISTER_SHA, 2,
|
|
236
|
+
@claims_key, Keyspace::ATTEMPTS,
|
|
237
|
+
envelope["id"], @processor_id, metadata,
|
|
238
|
+
)
|
|
239
|
+
rescue SolidRedis::CommandError => error
|
|
240
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
241
|
+
|
|
242
|
+
@redis_pool.call(
|
|
243
|
+
"EVAL", REGISTER, 2,
|
|
244
|
+
@claims_key, Keyspace::ATTEMPTS,
|
|
245
|
+
envelope["id"], @processor_id, metadata,
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
[claim_token, Integer(attempt)]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def next_channel(channels)
|
|
252
|
+
return channels.sample if @channel_order == :shuffle
|
|
253
|
+
|
|
254
|
+
channel = channels[@channel_index % channels.length]
|
|
255
|
+
@channel_index += 1
|
|
256
|
+
channel
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def claim_reliable(channels)
|
|
260
|
+
if @channel_order == :priority
|
|
261
|
+
channels.each do |source|
|
|
262
|
+
claim = claim_now(source)
|
|
263
|
+
return claim if claim
|
|
264
|
+
end
|
|
265
|
+
source = channels.first
|
|
266
|
+
else
|
|
267
|
+
source = next_channel(channels)
|
|
268
|
+
claim = claim_now(source)
|
|
269
|
+
return claim if claim
|
|
270
|
+
end
|
|
271
|
+
payload = @redis_pool.blocking_call(
|
|
272
|
+
TIMEOUT,
|
|
273
|
+
"BLMOVE", source, @claimed_key, "RIGHT", "LEFT", TIMEOUT,
|
|
274
|
+
)
|
|
275
|
+
payload && record(payload, source.delete_prefix("#{Keyspace::PREFIX}:channel:"))
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def claim_now(source)
|
|
279
|
+
claim_token = SecureRandom.uuid
|
|
280
|
+
claimed_at = Time.now.to_f
|
|
281
|
+
result = begin
|
|
282
|
+
@redis_pool.call(
|
|
283
|
+
"EVALSHA", RESERVE_SHA, 4,
|
|
284
|
+
source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
|
|
285
|
+
claim_token, @identity, @processor_id, claimed_at,
|
|
286
|
+
)
|
|
287
|
+
rescue SolidRedis::CommandError => error
|
|
288
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
289
|
+
|
|
290
|
+
@redis_pool.call(
|
|
291
|
+
"EVAL", RESERVE, 4,
|
|
292
|
+
source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
|
|
293
|
+
claim_token, @identity, @processor_id, claimed_at,
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
return unless result
|
|
297
|
+
|
|
298
|
+
payload, attempt = result
|
|
299
|
+
record(
|
|
300
|
+
payload,
|
|
301
|
+
source.delete_prefix("#{Keyspace::PREFIX}:channel:"),
|
|
302
|
+
claim_token: claim_token,
|
|
303
|
+
attempt: Integer(attempt),
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def refresh_paused
|
|
308
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
309
|
+
return if now < @paused_refresh_at
|
|
310
|
+
|
|
311
|
+
@paused = Array(@redis_pool.call("SMEMBERS", Keyspace::PAUSED_CHANNELS))
|
|
312
|
+
@paused_refresh_at = now + 5
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -5,7 +5,7 @@ require "socket"
|
|
|
5
5
|
require "timeout"
|
|
6
6
|
|
|
7
7
|
module SolidJobs
|
|
8
|
-
class
|
|
8
|
+
class Conductor
|
|
9
9
|
# Extra time granted beyond shutdown_timeout before a component is
|
|
10
10
|
# considered stuck, and how many times :stop is re-sent before giving up.
|
|
11
11
|
STOP_GRACE = 5.0
|
|
@@ -25,7 +25,7 @@ module SolidJobs
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def start
|
|
28
|
-
raise Error, "
|
|
28
|
+
raise Error, "Conductor is already running" if @started
|
|
29
29
|
|
|
30
30
|
warn_ruby34_multi_ractor
|
|
31
31
|
snapshot = Utilities.shareable_copy(
|
|
@@ -38,7 +38,7 @@ module SolidJobs
|
|
|
38
38
|
local_config = heartbeat = nil
|
|
39
39
|
begin
|
|
40
40
|
started = SolidJobs::StartupBarrier.boot(ready) do
|
|
41
|
-
local_config = SolidJobs::
|
|
41
|
+
local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
|
|
42
42
|
SolidJobs.use_config(local_config)
|
|
43
43
|
heartbeat = SolidJobs::Heartbeat.new(
|
|
44
44
|
local_config,
|
|
@@ -119,9 +119,9 @@ module SolidJobs
|
|
|
119
119
|
local_config = processor = nil
|
|
120
120
|
begin
|
|
121
121
|
started = SolidJobs::StartupBarrier.boot(ready_port) do
|
|
122
|
-
local_config = SolidJobs::
|
|
122
|
+
local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
|
|
123
123
|
SolidJobs.use_config(local_config)
|
|
124
|
-
processor = SolidJobs::
|
|
124
|
+
processor = SolidJobs::Engine.new(
|
|
125
125
|
local_config,
|
|
126
126
|
identity: settings.fetch(:identity),
|
|
127
127
|
processor_id: id,
|
|
@@ -161,9 +161,9 @@ module SolidJobs
|
|
|
161
161
|
local_config = scheduler = nil
|
|
162
162
|
begin
|
|
163
163
|
started = SolidJobs::StartupBarrier.boot(ready) do
|
|
164
|
-
local_config = SolidJobs::
|
|
164
|
+
local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
|
|
165
165
|
SolidJobs.use_config(local_config)
|
|
166
|
-
scheduler = SolidJobs::
|
|
166
|
+
scheduler = SolidJobs::Timer.new(local_config)
|
|
167
167
|
local_config.redis_pool.call("PING")
|
|
168
168
|
end
|
|
169
169
|
next :aborted unless started
|
|
@@ -227,8 +227,8 @@ module SolidJobs
|
|
|
227
227
|
|
|
228
228
|
RUBY34_MULTI_RACTOR_WARNING =
|
|
229
229
|
"SolidJobs: Ruby %s can deadlock the whole VM on a Ractor GC barrier " \
|
|
230
|
-
"under multi-
|
|
231
|
-
"servers on Ruby >= 4.0, or use concurrency: 1 (one process per
|
|
230
|
+
"under multi-Engine load (see docs/reliability.md). Run multi-Ractor " \
|
|
231
|
+
"servers on Ruby >= 4.0, or use concurrency: 1 (one process per Engine)."
|
|
232
232
|
|
|
233
233
|
def warn_ruby34_multi_ractor
|
|
234
234
|
return if RUBY_VERSION >= "4" || config.concurrency <= 1
|
|
@@ -265,7 +265,7 @@ module SolidJobs
|
|
|
265
265
|
end
|
|
266
266
|
|
|
267
267
|
def remote_signal
|
|
268
|
-
config.redis_pool.call("RPOP",
|
|
268
|
+
config.redis_pool.call("RPOP", Keyspace.node_signals(identity))
|
|
269
269
|
end
|
|
270
270
|
|
|
271
271
|
end
|
|
@@ -4,11 +4,11 @@ require "optparse"
|
|
|
4
4
|
require "yaml"
|
|
5
5
|
|
|
6
6
|
module SolidJobs
|
|
7
|
-
class
|
|
7
|
+
class Console
|
|
8
8
|
SIGNALS = %w[INT TERM TSTP TTIN INFO].freeze
|
|
9
9
|
|
|
10
10
|
def initialize
|
|
11
|
-
@options = {
|
|
11
|
+
@options = {channels: []}
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def run(arguments = ARGV)
|
|
@@ -16,7 +16,7 @@ module SolidJobs
|
|
|
16
16
|
load_configuration
|
|
17
17
|
load_application
|
|
18
18
|
apply_options
|
|
19
|
-
server =
|
|
19
|
+
server = Conductor.new(config: SolidJobs.config).start
|
|
20
20
|
read_io, write_io = IO.pipe
|
|
21
21
|
handlers = install_signal_handlers(write_io)
|
|
22
22
|
wait_for_shutdown(server, read_io)
|
|
@@ -38,8 +38,10 @@ module SolidJobs
|
|
|
38
38
|
options.banner = "Usage: solid-jobs [options]"
|
|
39
39
|
options.on("-r", "--require PATH", "Require an application file") { |value| @options[:require] = value }
|
|
40
40
|
options.on("-C", "--config PATH", "Load YAML configuration") { |value| @options[:config] = value }
|
|
41
|
-
options.on("-c", "--concurrency N", Integer, "
|
|
42
|
-
options.on("
|
|
41
|
+
options.on("-c", "--concurrency N", Integer, "Engine Ractor count") { |value| @options[:concurrency] = value }
|
|
42
|
+
options.on("--channel CHANNEL", "Channel name or name,weight") do |value|
|
|
43
|
+
@options[:channels] << channel(value)
|
|
44
|
+
end
|
|
43
45
|
options.on("-e", "--environment NAME", "Application environment") { |value| @options[:environment] = value }
|
|
44
46
|
options.on("-t", "--timeout SECONDS", Float, "Graceful shutdown timeout") { |value| @options[:timeout] = value }
|
|
45
47
|
options.on("-v", "--version", "Print version") do
|
|
@@ -62,7 +64,9 @@ module SolidJobs
|
|
|
62
64
|
values = values.fetch(@options[:environment], values) if @options[:environment]
|
|
63
65
|
@options[:concurrency] ||= values["concurrency"]
|
|
64
66
|
@options[:timeout] ||= values["timeout"]
|
|
65
|
-
|
|
67
|
+
if @options[:channels].empty?
|
|
68
|
+
@options[:channels] = Array(values["channels"]).map { |value| channel(value) }
|
|
69
|
+
end
|
|
66
70
|
if (url = values.dig("redis", "url") || values["redis_url"])
|
|
67
71
|
SolidJobs.config.redis = SolidRedis::Config.new(url: url)
|
|
68
72
|
end
|
|
@@ -77,7 +81,7 @@ module SolidJobs
|
|
|
77
81
|
def apply_options
|
|
78
82
|
ENV["RAILS_ENV"] = ENV["RACK_ENV"] = @options[:environment] if @options[:environment]
|
|
79
83
|
SolidJobs.config.concurrency = @options[:concurrency] if @options[:concurrency]
|
|
80
|
-
SolidJobs.config.
|
|
84
|
+
SolidJobs.config.channels = @options[:channels] unless @options[:channels].empty?
|
|
81
85
|
SolidJobs.config.shutdown_timeout = @options[:timeout] if @options[:timeout]
|
|
82
86
|
end
|
|
83
87
|
|
|
@@ -126,10 +130,9 @@ module SolidJobs
|
|
|
126
130
|
end
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
def
|
|
133
|
+
def channel(value)
|
|
130
134
|
name, weight = value.to_s.split(",", 2)
|
|
131
135
|
weight ? [name, Integer(weight)] : name
|
|
132
136
|
end
|
|
133
137
|
end
|
|
134
138
|
end
|
|
135
|
-
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module SolidJobs
|
|
6
|
-
class
|
|
6
|
+
class Engine
|
|
7
7
|
def initialize(config, identity: nil, processor_id: nil, heartbeat: nil)
|
|
8
8
|
@config = config
|
|
9
|
-
@
|
|
10
|
-
@
|
|
9
|
+
@claims = Claim.new(config, identity: identity, processor_id: processor_id)
|
|
10
|
+
@executor = Executor.new(redis_config: config.redis_config, config: config)
|
|
11
11
|
@identity = identity
|
|
12
12
|
@processor_id = processor_id
|
|
13
13
|
@heartbeat = heartbeat
|
|
@@ -29,12 +29,12 @@ module SolidJobs
|
|
|
29
29
|
next
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
next unless
|
|
32
|
+
claim = retrieve
|
|
33
|
+
next unless claim
|
|
34
34
|
|
|
35
35
|
receive_commands(control)
|
|
36
36
|
if @stopping || @quiet
|
|
37
|
-
requeue(
|
|
37
|
+
requeue(claim)
|
|
38
38
|
break if @stopping
|
|
39
39
|
|
|
40
40
|
next
|
|
@@ -42,19 +42,19 @@ module SolidJobs
|
|
|
42
42
|
|
|
43
43
|
begin
|
|
44
44
|
@state_mutex.synchronize { @busy = true }
|
|
45
|
-
|
|
46
|
-
register_work(
|
|
45
|
+
envelope = claim.envelope
|
|
46
|
+
register_work(claim, envelope)
|
|
47
47
|
begin
|
|
48
|
-
@
|
|
49
|
-
rescue
|
|
50
|
-
requeue(
|
|
48
|
+
@executor.execute(envelope)
|
|
49
|
+
rescue ExecutionHalt
|
|
50
|
+
requeue(claim)
|
|
51
51
|
break
|
|
52
52
|
rescue StandardError => error
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
FailurePolicy.call(config: @config, payload: envelope, error: error)
|
|
54
|
+
complete(claim)
|
|
55
55
|
failed += 1
|
|
56
56
|
else
|
|
57
|
-
processed += 1 if
|
|
57
|
+
processed += 1 if complete(claim)
|
|
58
58
|
end
|
|
59
59
|
ensure
|
|
60
60
|
@state_mutex.synchronize { @busy = false }
|
|
@@ -73,55 +73,55 @@ module SolidJobs
|
|
|
73
73
|
|
|
74
74
|
def interrupt_current
|
|
75
75
|
runner = @state_mutex.synchronize { @runner if @busy }
|
|
76
|
-
runner&.raise(
|
|
76
|
+
runner&.raise(ExecutionHalt, "SolidJobs shutdown timeout exceeded")
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
unless
|
|
81
|
+
def complete(claim)
|
|
82
|
+
completed = claim.complete
|
|
83
|
+
unless completed
|
|
84
84
|
@config.logger.warn(
|
|
85
|
-
"
|
|
85
|
+
"Claim fencing rejected stale completion: #{claim.claim_token}",
|
|
86
86
|
)
|
|
87
87
|
end
|
|
88
|
-
|
|
88
|
+
completed
|
|
89
89
|
rescue SolidRedis::ConnectionError => error
|
|
90
90
|
@fetcher.connection_failed!
|
|
91
91
|
@config.logger.warn(
|
|
92
|
-
"Redis
|
|
92
|
+
"Redis completion failed; claimed task will be replayed: #{error.class}: #{error.message}",
|
|
93
93
|
)
|
|
94
94
|
false
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
def requeue(
|
|
98
|
-
requeued =
|
|
97
|
+
def requeue(claim)
|
|
98
|
+
requeued = claim.requeue
|
|
99
99
|
unless requeued
|
|
100
100
|
@config.logger.warn(
|
|
101
|
-
"
|
|
101
|
+
"Claim fencing rejected stale requeue: #{claim.claim_token}",
|
|
102
102
|
)
|
|
103
103
|
end
|
|
104
104
|
requeued
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def retrieve
|
|
108
|
-
@
|
|
108
|
+
@claims.next
|
|
109
109
|
rescue SolidRedis::ConnectionError => error
|
|
110
|
-
@
|
|
111
|
-
@config.logger.warn("Redis
|
|
110
|
+
@claims.connection_failed!
|
|
111
|
+
@config.logger.warn("Redis claim failed, retrying: #{error.class}: #{error.message}")
|
|
112
112
|
sleep 0.1
|
|
113
113
|
nil
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
def register_work(
|
|
116
|
+
def register_work(claim, envelope)
|
|
117
117
|
return unless @identity
|
|
118
118
|
|
|
119
119
|
work = JSON.generate(
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"attempt" =>
|
|
120
|
+
"channel" => claim.channel,
|
|
121
|
+
"envelope" => envelope,
|
|
122
|
+
"started_at" => Time.now.to_f,
|
|
123
|
+
"claim_token" => claim.claim_token,
|
|
124
|
+
"attempt" => claim.attempt,
|
|
125
125
|
)
|
|
126
126
|
send_heartbeat(:work, @processor_id, work)
|
|
127
127
|
end
|
data/lib/solid_jobs/errors.rb
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class Executor
|
|
7
|
+
Execution = Data.define(:task, :envelope)
|
|
8
|
+
|
|
9
|
+
attr_reader :config
|
|
10
|
+
|
|
11
|
+
def initialize(redis_config:, config: nil)
|
|
12
|
+
@config = config || Blueprint.new(redis: redis_config)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(envelope)
|
|
16
|
+
envelope = JSON.parse(envelope) if envelope.is_a?(String)
|
|
17
|
+
raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
task_name = envelope["task"]
|
|
20
|
+
raise InvalidJobError, "Task must be present" unless task_name.is_a?(String) && !task_name.empty?
|
|
21
|
+
raise InvalidJobError, "Task arguments must be an Array" unless envelope["arguments"].is_a?(Array)
|
|
22
|
+
unless envelope["channel"].is_a?(String) && !envelope["channel"].empty?
|
|
23
|
+
raise InvalidJobError, "Task channel must be present"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
task = Utilities.constantize(task_name).new
|
|
27
|
+
task.task_id = envelope["id"] if task.respond_to?(:task_id=)
|
|
28
|
+
operation = -> { task.execute_task(*envelope.fetch("arguments")) }
|
|
29
|
+
return operation.call if config.execute_interceptors.empty?
|
|
30
|
+
|
|
31
|
+
config.execute_interceptors.call(Execution.new(task, envelope), &operation)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|