solid-jobs 0.1.2 → 0.2.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 +38 -2
- data/README.md +137 -117
- data/docs/reliability.md +71 -89
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +6 -7
- data/lib/solid_jobs/active_job.rb +2 -5
- data/lib/solid_jobs/api.rb +133 -156
- data/lib/solid_jobs/claim.rb +315 -0
- data/lib/solid_jobs/cli.rb +9 -6
- data/lib/solid_jobs/config.rb +50 -50
- 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/processor.rb +30 -30
- data/lib/solid_jobs/publisher.rb +186 -0
- data/lib/solid_jobs/recovery.rb +8 -7
- data/lib/solid_jobs/server.rb +2 -2
- data/lib/solid_jobs/task.rb +147 -0
- data/lib/solid_jobs/testing.rb +18 -18
- data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
- data/lib/solid_jobs/version.rb +1 -2
- data/lib/solid_jobs.rb +18 -18
- metadata +13 -32
- 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
|
data/lib/solid_jobs/cli.rb
CHANGED
|
@@ -8,7 +8,7 @@ module SolidJobs
|
|
|
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)
|
|
@@ -39,7 +39,9 @@ module SolidJobs
|
|
|
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
41
|
options.on("-c", "--concurrency N", Integer, "Processor Ractor count") { |value| @options[:concurrency] = value }
|
|
42
|
-
options.on("
|
|
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
|
-
|
data/lib/solid_jobs/config.rb
CHANGED
|
@@ -4,35 +4,35 @@ require "logger"
|
|
|
4
4
|
|
|
5
5
|
module SolidJobs
|
|
6
6
|
class Config
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
7
|
+
DEFAULT_TASK_OPTIONS = {
|
|
8
|
+
"channel" => "default",
|
|
9
|
+
"max_failures" => 25,
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
|
-
attr_accessor :concurrency, :
|
|
12
|
+
attr_accessor :concurrency, :discarded_limit, :discarded_retention, :logger,
|
|
13
13
|
:on_complex_arguments, :poll_interval_average, :shutdown_timeout,
|
|
14
14
|
:reliable_fetch, :retry_base_delay, :retry_max_delay
|
|
15
|
-
attr_reader :
|
|
16
|
-
:
|
|
15
|
+
attr_reader :default_task_options, :error_handlers, :execute_interceptors,
|
|
16
|
+
:publish_interceptors, :redis_config
|
|
17
17
|
|
|
18
|
-
def initialize(redis: nil, concurrency: 5,
|
|
18
|
+
def initialize(redis: nil, concurrency: 5, channels: ["default"])
|
|
19
19
|
@redis_config = redis || SolidRedis.config(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
|
|
20
20
|
@concurrency = Integer(concurrency)
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@
|
|
21
|
+
@channels = normalize_channels(channels)
|
|
22
|
+
@default_task_options = DEFAULT_TASK_OPTIONS
|
|
23
|
+
@publish_interceptors = InterceptorRegistry.new
|
|
24
|
+
@execute_interceptors = InterceptorRegistry.new
|
|
25
25
|
@error_handlers = []
|
|
26
26
|
@lifecycle_callbacks = Hash.new { |hash, event| hash[event] = [] }
|
|
27
27
|
@on_complex_arguments = :raise
|
|
28
28
|
@poll_interval_average = 5.0
|
|
29
29
|
@shutdown_timeout = 25.0
|
|
30
|
-
@
|
|
30
|
+
@channel_order = :weighted
|
|
31
31
|
@reliable_fetch = true
|
|
32
32
|
@retry_base_delay = 15.0
|
|
33
33
|
@retry_max_delay = 3_600.0
|
|
34
|
-
@
|
|
35
|
-
@
|
|
34
|
+
@discarded_limit = 10_000
|
|
35
|
+
@discarded_retention = 180 * 24 * 60 * 60
|
|
36
36
|
@logger = Logger.new($stdout)
|
|
37
37
|
@redis_pool = nil
|
|
38
38
|
end
|
|
@@ -50,42 +50,42 @@ module SolidJobs
|
|
|
50
50
|
redis_pool.with { |connection| yield connection }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def
|
|
54
|
-
@
|
|
53
|
+
def channels
|
|
54
|
+
@channels.map(&:first)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
@
|
|
57
|
+
def channels=(values)
|
|
58
|
+
@channels = normalize_channels(values)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def
|
|
62
|
-
@
|
|
61
|
+
def channel_entries
|
|
62
|
+
@channels.dup
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
def
|
|
66
|
-
@
|
|
65
|
+
def channel_order
|
|
66
|
+
@channel_order
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
def
|
|
69
|
+
def channel_order=(mode)
|
|
70
70
|
mode = mode.to_sym
|
|
71
|
-
unless %i[
|
|
72
|
-
raise ArgumentError, "
|
|
71
|
+
unless %i[priority weighted shuffle].include?(mode)
|
|
72
|
+
raise ArgumentError, "channel_order must be :priority, :weighted, or :shuffle"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
@
|
|
75
|
+
@channel_order = mode
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def
|
|
79
|
-
|
|
78
|
+
def prioritized?
|
|
79
|
+
channel_order == :priority
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
self.
|
|
82
|
+
def prioritized=(value)
|
|
83
|
+
self.channel_order = value ? :priority : :weighted
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
def
|
|
87
|
-
@
|
|
88
|
-
|
|
86
|
+
def default_task_options=(options)
|
|
87
|
+
@default_task_options = Utilities.shareable_copy(
|
|
88
|
+
DEFAULT_TASK_OPTIONS.merge(Utilities.stringify_keys(options)),
|
|
89
89
|
)
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -111,26 +111,26 @@ module SolidJobs
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def inspect
|
|
114
|
-
"#<#{self.class.name} concurrency=#{concurrency}
|
|
114
|
+
"#<#{self.class.name} concurrency=#{concurrency} channels=#{channels.inspect}>"
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
def ractor_snapshot
|
|
118
118
|
Utilities.shareable_copy(
|
|
119
119
|
redis_config: redis_config,
|
|
120
120
|
concurrency: concurrency,
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
channels: channel_entries,
|
|
122
|
+
channel_order: channel_order,
|
|
123
123
|
reliable_fetch: reliable_fetch,
|
|
124
|
-
|
|
124
|
+
default_task_options: default_task_options,
|
|
125
125
|
on_complex_arguments: on_complex_arguments,
|
|
126
126
|
poll_interval_average: poll_interval_average,
|
|
127
127
|
shutdown_timeout: shutdown_timeout,
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
discarded_limit: discarded_limit,
|
|
129
|
+
discarded_retention: discarded_retention,
|
|
130
130
|
retry_base_delay: retry_base_delay,
|
|
131
131
|
retry_max_delay: retry_max_delay,
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
publish_interceptors: publish_interceptors.export,
|
|
133
|
+
execute_interceptors: execute_interceptors.export,
|
|
134
134
|
)
|
|
135
135
|
end
|
|
136
136
|
|
|
@@ -138,31 +138,31 @@ module SolidJobs
|
|
|
138
138
|
config = new(
|
|
139
139
|
redis: snapshot.fetch(:redis_config),
|
|
140
140
|
concurrency: snapshot.fetch(:concurrency),
|
|
141
|
-
|
|
141
|
+
channels: snapshot.fetch(:channels),
|
|
142
142
|
)
|
|
143
|
-
config.
|
|
143
|
+
config.channel_order = snapshot.fetch(:channel_order)
|
|
144
144
|
config.reliable_fetch = snapshot.fetch(:reliable_fetch)
|
|
145
|
-
config.
|
|
145
|
+
config.default_task_options = snapshot.fetch(:default_task_options)
|
|
146
146
|
config.on_complex_arguments = snapshot.fetch(:on_complex_arguments)
|
|
147
147
|
config.poll_interval_average = snapshot.fetch(:poll_interval_average)
|
|
148
148
|
config.shutdown_timeout = snapshot.fetch(:shutdown_timeout)
|
|
149
|
-
config.
|
|
150
|
-
config.
|
|
149
|
+
config.discarded_limit = snapshot.fetch(:discarded_limit)
|
|
150
|
+
config.discarded_retention = snapshot.fetch(:discarded_retention)
|
|
151
151
|
config.retry_base_delay = snapshot.fetch(:retry_base_delay)
|
|
152
152
|
config.retry_max_delay = snapshot.fetch(:retry_max_delay)
|
|
153
|
-
config.
|
|
154
|
-
config.
|
|
153
|
+
config.publish_interceptors.import(snapshot.fetch(:publish_interceptors))
|
|
154
|
+
config.execute_interceptors.import(snapshot.fetch(:execute_interceptors))
|
|
155
155
|
config
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
private
|
|
159
159
|
|
|
160
|
-
def
|
|
160
|
+
def normalize_channels(values)
|
|
161
161
|
Array(values).map do |value|
|
|
162
162
|
name, weight = value.is_a?(Array) ? value : [value, 1]
|
|
163
163
|
name = String(name)
|
|
164
164
|
weight = Integer(weight)
|
|
165
|
-
raise ArgumentError, "
|
|
165
|
+
raise ArgumentError, "Channel weight must be positive" unless weight.positive?
|
|
166
166
|
|
|
167
167
|
[name.freeze, weight].freeze
|
|
168
168
|
end
|
|
@@ -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 || Config.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.perform(*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
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class FailurePolicy < Service::Base
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
payload = @payload.dup
|
|
10
|
+
now = Time.now
|
|
11
|
+
now_ms = Utilities.realtime_milliseconds
|
|
12
|
+
failure_count = Integer(payload.fetch("failure_count", 0)) + 1
|
|
13
|
+
payload["exception_message"] = @error.message.to_s
|
|
14
|
+
payload["exception_type"] = @error.class.name
|
|
15
|
+
payload["first_failed_ms"] ||= now_ms
|
|
16
|
+
payload["last_failed_ms"] = now_ms
|
|
17
|
+
payload["failure_count"] = failure_count
|
|
18
|
+
payload["channel"] = payload["retry_channel"] if payload["retry_channel"]
|
|
19
|
+
|
|
20
|
+
if retry_allowed?(payload, failure_count, now)
|
|
21
|
+
decision = retry_decision(payload, failure_count)
|
|
22
|
+
return nil if decision == :drop
|
|
23
|
+
return archive(payload, now.to_f) if decision == :archive
|
|
24
|
+
|
|
25
|
+
next_attempt = now.to_f + (decision || retry_delay(failure_count))
|
|
26
|
+
@config.redis_pool.call("ZADD", Keyspace::RETRIES, next_attempt, JSON.generate(payload))
|
|
27
|
+
append_message("retry scheduled")
|
|
28
|
+
next_attempt
|
|
29
|
+
else
|
|
30
|
+
invoke_final_failure(payload)
|
|
31
|
+
archive(payload, now.to_f)
|
|
32
|
+
append_message("task discarded")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def retry_allowed?(payload, failure_count, now)
|
|
40
|
+
if payload["retry_within"]
|
|
41
|
+
first_failure = Float(payload["first_failed_ms"]) / 1_000
|
|
42
|
+
now.to_f - first_failure < Float(payload["retry_within"])
|
|
43
|
+
else
|
|
44
|
+
maximum = Integer(payload.fetch("max_failures", 0))
|
|
45
|
+
failure_count <= maximum
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def retry_delay(failure_count)
|
|
50
|
+
ceiling = [
|
|
51
|
+
Float(@config.retry_base_delay) * (2**(failure_count - 1)),
|
|
52
|
+
Float(@config.retry_max_delay),
|
|
53
|
+
].min
|
|
54
|
+
(ceiling / 2) + (rand * ceiling / 2)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def retry_decision(payload, failure_count)
|
|
58
|
+
task = resolve_task(payload)
|
|
59
|
+
return unless task.respond_to?(:retry_delay_callback)
|
|
60
|
+
|
|
61
|
+
value = task.retry_delay_callback(failure_count, @error, payload)
|
|
62
|
+
return value if value.is_a?(Numeric) || %i[drop archive].include?(value)
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invoke_final_failure(payload)
|
|
68
|
+
task = resolve_task(payload)
|
|
69
|
+
return unless task.respond_to?(:final_failure_callback)
|
|
70
|
+
|
|
71
|
+
task.final_failure_callback(payload, @error)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve_task(payload)
|
|
75
|
+
Utilities.constantize(payload.fetch("task"))
|
|
76
|
+
rescue NameError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def archive(payload, timestamp)
|
|
81
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
82
|
+
pipeline.call("ZADD", Keyspace::DISCARDED, timestamp, JSON.generate(payload))
|
|
83
|
+
pipeline.call(
|
|
84
|
+
"ZREMRANGEBYSCORE",
|
|
85
|
+
Keyspace::DISCARDED,
|
|
86
|
+
"-inf",
|
|
87
|
+
timestamp - @config.discarded_retention,
|
|
88
|
+
)
|
|
89
|
+
pipeline.call(
|
|
90
|
+
"ZREMRANGEBYRANK",
|
|
91
|
+
Keyspace::DISCARDED,
|
|
92
|
+
0,
|
|
93
|
+
-(@config.discarded_limit + 1),
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|