solid-jobs 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +22 -0
- data/README.md +159 -0
- data/docs/reliability.md +114 -0
- data/exe/solid-jobs +8 -0
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +42 -0
- data/lib/solid_jobs/active_job.rb +16 -0
- data/lib/solid_jobs/api.rb +457 -0
- data/lib/solid_jobs/cli.rb +135 -0
- data/lib/solid_jobs/client.rb +202 -0
- data/lib/solid_jobs/config.rb +171 -0
- data/lib/solid_jobs/errors.rb +9 -0
- data/lib/solid_jobs/fetch.rb +310 -0
- data/lib/solid_jobs/heartbeat.rb +60 -0
- data/lib/solid_jobs/integrity_check.rb +222 -0
- data/lib/solid_jobs/job.rb +156 -0
- data/lib/solid_jobs/middleware/chain.rb +104 -0
- data/lib/solid_jobs/processor.rb +152 -0
- data/lib/solid_jobs/ractor_support.rb +11 -0
- data/lib/solid_jobs/rails.rb +16 -0
- data/lib/solid_jobs/recovery.rb +65 -0
- data/lib/solid_jobs/retry_service.rb +100 -0
- data/lib/solid_jobs/scheduler.rb +64 -0
- data/lib/solid_jobs/server.rb +221 -0
- data/lib/solid_jobs/startup_barrier.rb +126 -0
- data/lib/solid_jobs/testing.rb +69 -0
- data/lib/solid_jobs/utilities.rb +52 -0
- data/lib/solid_jobs/version.rb +6 -0
- data/lib/solid_jobs/worker.rb +35 -0
- data/lib/solid_jobs.rb +92 -0
- metadata +189 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Client
|
|
8
|
+
DEFAULT_BATCH_SIZE = 1_000
|
|
9
|
+
SCHEDULED_BATCH_SIZE = 100
|
|
10
|
+
|
|
11
|
+
def self.push(item)
|
|
12
|
+
new.push(item)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.push_bulk(item)
|
|
16
|
+
new.push_bulk(item)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(config: nil, configuration: nil)
|
|
20
|
+
selected = config || configuration || SolidJobs.config
|
|
21
|
+
@config = selected.is_a?(SolidRedis::Config) ? Config.new(redis: selected) : selected
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def push(item)
|
|
25
|
+
payload = normalize_item(item)
|
|
26
|
+
payload = invoke_middleware(payload)
|
|
27
|
+
return unless payload
|
|
28
|
+
|
|
29
|
+
verify_json!(payload)
|
|
30
|
+
raw_push([payload])
|
|
31
|
+
payload["jid"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def push_bulk(item)
|
|
35
|
+
raise InvalidJobError, "Job payload must be a Hash" unless item.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
source = Utilities.stringify_hash_keys(item)
|
|
38
|
+
args = source.delete("args")
|
|
39
|
+
raise InvalidJobError, "Job 'args' must be an Array or Enumerable" unless args.respond_to?(:each)
|
|
40
|
+
|
|
41
|
+
ats = source.delete("at")
|
|
42
|
+
if ats && !ats.is_a?(Numeric) && !ats.is_a?(Array)
|
|
43
|
+
raise InvalidJobError, "Job 'at' must be a Numeric or an Array"
|
|
44
|
+
end
|
|
45
|
+
if ats.is_a?(Array) && args.respond_to?(:size) && ats.size != args.size
|
|
46
|
+
raise InvalidJobError, "Job 'at' Array must have the same size as 'args'"
|
|
47
|
+
end
|
|
48
|
+
batch_size = Integer(source.delete("batch_size") || (ats ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE))
|
|
49
|
+
raise ArgumentError, "batch_size must be positive" unless batch_size.positive?
|
|
50
|
+
|
|
51
|
+
explicit_jid = source.delete("jid")
|
|
52
|
+
if explicit_jid && args.respond_to?(:size) && args.size > 1
|
|
53
|
+
raise InvalidJobError, "Explicit jid is only supported for one bulk job"
|
|
54
|
+
end
|
|
55
|
+
base = normalize_item(source.merge("args" => []))
|
|
56
|
+
base.delete("jid")
|
|
57
|
+
result = []
|
|
58
|
+
args.each_slice(batch_size).with_index do |batch, batch_index|
|
|
59
|
+
payloads = []
|
|
60
|
+
batch.each_with_index do |job_args, index|
|
|
61
|
+
raise InvalidJobError, "Bulk job arguments must be Arrays" unless job_args.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
payload = base.merge(
|
|
64
|
+
"args" => job_args,
|
|
65
|
+
"jid" => explicit_jid || SecureRandom.hex(12),
|
|
66
|
+
)
|
|
67
|
+
payload["at"] = ats.is_a?(Array) ? ats[batch_index * batch_size + index] : ats if ats
|
|
68
|
+
normalized = invoke_middleware(payload)
|
|
69
|
+
if normalized
|
|
70
|
+
verify_json!(normalized)
|
|
71
|
+
payloads << normalized
|
|
72
|
+
result << normalized["jid"]
|
|
73
|
+
else
|
|
74
|
+
result << nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
raw_push(payloads)
|
|
78
|
+
end
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def raw_push(payloads)
|
|
83
|
+
return true if payloads.empty?
|
|
84
|
+
|
|
85
|
+
if defined?(Testing)
|
|
86
|
+
case Testing.mode
|
|
87
|
+
when :fake
|
|
88
|
+
payloads.each do |payload|
|
|
89
|
+
Testing.jobs_for(Utilities.constantize(payload["class"])) << payload
|
|
90
|
+
end
|
|
91
|
+
return true
|
|
92
|
+
when :inline
|
|
93
|
+
payloads.each { |payload| Testing.execute_inline(payload) }
|
|
94
|
+
return true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
99
|
+
if payloads.all? { |payload| payload["at"] }
|
|
100
|
+
payloads.each do |payload|
|
|
101
|
+
score = payload.delete("at")
|
|
102
|
+
pipeline.call("ZADD", "schedule", score, JSON.generate(payload))
|
|
103
|
+
end
|
|
104
|
+
elsif payloads.none? { |payload| payload["at"] }
|
|
105
|
+
push_immediate(pipeline, payloads)
|
|
106
|
+
else
|
|
107
|
+
scheduled, immediate = payloads.partition { |payload| payload["at"] }
|
|
108
|
+
scheduled.each do |payload|
|
|
109
|
+
score = payload.delete("at")
|
|
110
|
+
pipeline.call("ZADD", "schedule", score, JSON.generate(payload))
|
|
111
|
+
end
|
|
112
|
+
push_immediate(pipeline, immediate)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def normalize_item(item)
|
|
121
|
+
raise InvalidJobError, "Job payload must be a Hash" unless item.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
payload = @config.default_job_options.merge(Utilities.stringify_hash_keys(item))
|
|
124
|
+
payload["class"] ||= payload.delete("job_class")
|
|
125
|
+
payload["jid"] ||= payload.delete("job_id")
|
|
126
|
+
job_class = payload["class"]
|
|
127
|
+
args = payload["args"]
|
|
128
|
+
unless job_class.is_a?(Class) || (job_class.is_a?(String) && !job_class.empty?)
|
|
129
|
+
raise InvalidJobError, "Job must include a non-empty class"
|
|
130
|
+
end
|
|
131
|
+
raise InvalidJobError, "Job args must be an Array" unless args.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
payload["class"] = job_class.name if job_class.is_a?(Class)
|
|
134
|
+
payload["queue"] = String(payload.fetch("queue"))
|
|
135
|
+
payload["jid"] ||= SecureRandom.hex(12)
|
|
136
|
+
payload["created_at"] ||= Utilities.realtime_milliseconds
|
|
137
|
+
payload
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def invoke_middleware(payload)
|
|
141
|
+
job_class = payload["class"]
|
|
142
|
+
@config.client_middleware.invoke(job_class, payload, payload["queue"], @config.redis_pool) do
|
|
143
|
+
payload
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def push_immediate(pipeline, payloads)
|
|
148
|
+
return if payloads.empty?
|
|
149
|
+
|
|
150
|
+
groups = if payloads.all? { |payload| payload["queue"] == payloads.first["queue"] }
|
|
151
|
+
[[payloads.first["queue"], payloads]]
|
|
152
|
+
else
|
|
153
|
+
payloads.group_by { |payload| payload["queue"] }
|
|
154
|
+
end
|
|
155
|
+
groups.each do |queue, jobs|
|
|
156
|
+
pipeline.call("SADD", "queues", queue)
|
|
157
|
+
now = Utilities.realtime_milliseconds
|
|
158
|
+
encoded = jobs.map do |payload|
|
|
159
|
+
payload["enqueued_at"] = now
|
|
160
|
+
JSON.generate(payload)
|
|
161
|
+
end
|
|
162
|
+
pipeline.call("LPUSH", "queue:#{queue}", *encoded)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def verify_json!(payload)
|
|
167
|
+
invalid = find_invalid_json_value(payload["args"])
|
|
168
|
+
return unless invalid
|
|
169
|
+
|
|
170
|
+
message = "Job arguments must contain only JSON-native values with string hash keys; found #{invalid.inspect}"
|
|
171
|
+
case @config.on_complex_arguments
|
|
172
|
+
when :raise
|
|
173
|
+
raise InvalidArgumentError, message
|
|
174
|
+
when :warn
|
|
175
|
+
@config.logger.warn(message)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def find_invalid_json_value(value)
|
|
180
|
+
case value
|
|
181
|
+
when nil, true, false, String, Integer, Float
|
|
182
|
+
nil
|
|
183
|
+
when Array
|
|
184
|
+
value.each do |nested|
|
|
185
|
+
invalid = find_invalid_json_value(nested)
|
|
186
|
+
return invalid if invalid
|
|
187
|
+
end
|
|
188
|
+
nil
|
|
189
|
+
when Hash
|
|
190
|
+
value.each do |key, nested|
|
|
191
|
+
return key unless key.is_a?(String)
|
|
192
|
+
|
|
193
|
+
invalid = find_invalid_json_value(nested)
|
|
194
|
+
return invalid if invalid
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
197
|
+
else
|
|
198
|
+
value
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT_JOB_OPTIONS = {
|
|
8
|
+
"queue" => "default",
|
|
9
|
+
"retry" => true,
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :concurrency, :dead_max_jobs, :dead_timeout, :logger,
|
|
13
|
+
:on_complex_arguments, :poll_interval_average, :shutdown_timeout,
|
|
14
|
+
:reliable_fetch, :retry_base_delay, :retry_max_delay
|
|
15
|
+
attr_reader :client_middleware, :default_job_options, :error_handlers,
|
|
16
|
+
:redis_config, :server_middleware
|
|
17
|
+
|
|
18
|
+
def initialize(redis: nil, concurrency: 5, queues: ["default"])
|
|
19
|
+
@redis_config = redis || SolidRedis.config(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
|
|
20
|
+
@concurrency = Integer(concurrency)
|
|
21
|
+
@queues = normalize_queues(queues)
|
|
22
|
+
@default_job_options = DEFAULT_JOB_OPTIONS
|
|
23
|
+
@client_middleware = Middleware::Chain.new
|
|
24
|
+
@server_middleware = Middleware::Chain.new
|
|
25
|
+
@error_handlers = []
|
|
26
|
+
@lifecycle_callbacks = Hash.new { |hash, event| hash[event] = [] }
|
|
27
|
+
@on_complex_arguments = :raise
|
|
28
|
+
@poll_interval_average = 5.0
|
|
29
|
+
@shutdown_timeout = 25.0
|
|
30
|
+
@queue_mode = :weighted
|
|
31
|
+
@reliable_fetch = true
|
|
32
|
+
@retry_base_delay = 15.0
|
|
33
|
+
@retry_max_delay = 3_600.0
|
|
34
|
+
@dead_max_jobs = 10_000
|
|
35
|
+
@dead_timeout = 180 * 24 * 60 * 60
|
|
36
|
+
@logger = Logger.new($stdout)
|
|
37
|
+
@redis_pool = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def redis=(configuration)
|
|
41
|
+
close
|
|
42
|
+
@redis_config = configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def redis_pool
|
|
46
|
+
@redis_pool ||= redis_config.new_pool(size: concurrency + 2, timeout: 1.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def redis
|
|
50
|
+
redis_pool.with { |connection| yield connection }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def queues
|
|
54
|
+
@queues.map(&:first)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def queues=(values)
|
|
58
|
+
@queues = normalize_queues(values)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def queue_entries
|
|
62
|
+
@queues.dup
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def queue_mode
|
|
66
|
+
@queue_mode
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def queue_mode=(mode)
|
|
70
|
+
mode = mode.to_sym
|
|
71
|
+
unless %i[strict weighted random].include?(mode)
|
|
72
|
+
raise ArgumentError, "queue_mode must be :strict, :weighted, or :random"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@queue_mode = mode
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def strict
|
|
79
|
+
queue_mode == :strict
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def strict=(value)
|
|
83
|
+
self.queue_mode = value ? :strict : :weighted
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def default_job_options=(options)
|
|
87
|
+
@default_job_options = Utilities.shareable_copy(
|
|
88
|
+
DEFAULT_JOB_OPTIONS.merge(Utilities.stringify_keys(options)),
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def on(event, &block)
|
|
93
|
+
raise ArgumentError, "A lifecycle callback block is required" unless block
|
|
94
|
+
|
|
95
|
+
@lifecycle_callbacks[event.to_sym] << CallbackCollection.new do |callbacks|
|
|
96
|
+
callbacks.public_send(event, &block)
|
|
97
|
+
end
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fire(event, *arguments)
|
|
102
|
+
@lifecycle_callbacks[event.to_sym].each do |callbacks|
|
|
103
|
+
callbacks.respond_with(event, *arguments)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
@redis_pool&.close
|
|
109
|
+
@redis_pool = nil
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def inspect
|
|
114
|
+
"#<#{self.class.name} concurrency=#{concurrency} queues=#{queues.inspect}>"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def ractor_snapshot
|
|
118
|
+
Utilities.shareable_copy(
|
|
119
|
+
redis_config: redis_config,
|
|
120
|
+
concurrency: concurrency,
|
|
121
|
+
queues: queue_entries,
|
|
122
|
+
queue_mode: queue_mode,
|
|
123
|
+
reliable_fetch: reliable_fetch,
|
|
124
|
+
default_job_options: default_job_options,
|
|
125
|
+
on_complex_arguments: on_complex_arguments,
|
|
126
|
+
poll_interval_average: poll_interval_average,
|
|
127
|
+
shutdown_timeout: shutdown_timeout,
|
|
128
|
+
dead_max_jobs: dead_max_jobs,
|
|
129
|
+
dead_timeout: dead_timeout,
|
|
130
|
+
retry_base_delay: retry_base_delay,
|
|
131
|
+
retry_max_delay: retry_max_delay,
|
|
132
|
+
client_middleware: client_middleware.snapshot,
|
|
133
|
+
server_middleware: server_middleware.snapshot,
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.from_ractor_snapshot(snapshot)
|
|
138
|
+
config = new(
|
|
139
|
+
redis: snapshot.fetch(:redis_config),
|
|
140
|
+
concurrency: snapshot.fetch(:concurrency),
|
|
141
|
+
queues: snapshot.fetch(:queues),
|
|
142
|
+
)
|
|
143
|
+
config.queue_mode = snapshot.fetch(:queue_mode)
|
|
144
|
+
config.reliable_fetch = snapshot.fetch(:reliable_fetch)
|
|
145
|
+
config.default_job_options = snapshot.fetch(:default_job_options)
|
|
146
|
+
config.on_complex_arguments = snapshot.fetch(:on_complex_arguments)
|
|
147
|
+
config.poll_interval_average = snapshot.fetch(:poll_interval_average)
|
|
148
|
+
config.shutdown_timeout = snapshot.fetch(:shutdown_timeout)
|
|
149
|
+
config.dead_max_jobs = snapshot.fetch(:dead_max_jobs)
|
|
150
|
+
config.dead_timeout = snapshot.fetch(:dead_timeout)
|
|
151
|
+
config.retry_base_delay = snapshot.fetch(:retry_base_delay)
|
|
152
|
+
config.retry_max_delay = snapshot.fetch(:retry_max_delay)
|
|
153
|
+
config.client_middleware.restore(snapshot.fetch(:client_middleware))
|
|
154
|
+
config.server_middleware.restore(snapshot.fetch(:server_middleware))
|
|
155
|
+
config
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def normalize_queues(values)
|
|
161
|
+
Array(values).map do |value|
|
|
162
|
+
name, weight = value.is_a?(Array) ? value : [value, 1]
|
|
163
|
+
name = String(name)
|
|
164
|
+
weight = Integer(weight)
|
|
165
|
+
raise ArgumentError, "Queue weight must be positive" unless weight.positive?
|
|
166
|
+
|
|
167
|
+
[name.freeze, weight].freeze
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "digest/sha1"
|
|
6
|
+
|
|
7
|
+
module SolidJobs
|
|
8
|
+
class Fetch
|
|
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["jid"], 1)
|
|
19
|
+
local metadata = {
|
|
20
|
+
job_id = job["jid"],
|
|
21
|
+
reservation_id = ARGV[1],
|
|
22
|
+
process_id = ARGV[2],
|
|
23
|
+
worker_id = ARGV[3],
|
|
24
|
+
queue = job["queue"],
|
|
25
|
+
reserved_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)["reservation_id"] ~= 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)["reservation_id"] ~= 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
|
+
UnitOfWork = Struct.new(
|
|
74
|
+
:queue,
|
|
75
|
+
:payload,
|
|
76
|
+
:job,
|
|
77
|
+
:redis_pool,
|
|
78
|
+
:reserved_key,
|
|
79
|
+
:metadata_key,
|
|
80
|
+
:metadata_field,
|
|
81
|
+
:reservation_id,
|
|
82
|
+
:attempt,
|
|
83
|
+
keyword_init: true,
|
|
84
|
+
) do
|
|
85
|
+
def acknowledge
|
|
86
|
+
if reserved_key
|
|
87
|
+
removed = begin
|
|
88
|
+
redis_pool.call(
|
|
89
|
+
"EVALSHA", Fetch::ACK_SHA, 3,
|
|
90
|
+
reserved_key, metadata_key, "solid-jobs:attempts",
|
|
91
|
+
payload, metadata_field, reservation_id, job.fetch("jid"),
|
|
92
|
+
)
|
|
93
|
+
rescue SolidRedis::CommandError => error
|
|
94
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
95
|
+
|
|
96
|
+
redis_pool.call(
|
|
97
|
+
"EVAL", Fetch::ACK, 3,
|
|
98
|
+
reserved_key, metadata_key, "solid-jobs:attempts",
|
|
99
|
+
payload, metadata_field, reservation_id, job.fetch("jid"),
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
return Integer(removed) == 1
|
|
103
|
+
end
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def requeue
|
|
108
|
+
if reserved_key
|
|
109
|
+
destination = "queue:#{queue}"
|
|
110
|
+
removed = begin
|
|
111
|
+
redis_pool.call(
|
|
112
|
+
"EVALSHA", Fetch::REQUEUE_SHA, 3,
|
|
113
|
+
reserved_key, metadata_key, destination,
|
|
114
|
+
payload, metadata_field, reservation_id,
|
|
115
|
+
)
|
|
116
|
+
rescue SolidRedis::CommandError => error
|
|
117
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
118
|
+
|
|
119
|
+
redis_pool.call(
|
|
120
|
+
"EVAL", Fetch::REQUEUE, 3,
|
|
121
|
+
reserved_key, metadata_key, destination,
|
|
122
|
+
payload, metadata_field, reservation_id,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
return Integer(removed) == 1
|
|
126
|
+
else
|
|
127
|
+
redis_pool.call("RPUSH", "queue:#{queue}", 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
|
+
@queue_mode = config.queue_mode
|
|
136
|
+
@reliable = config.reliable_fetch
|
|
137
|
+
@queues = config.queue_entries.flat_map do |name, weight|
|
|
138
|
+
Array.new(weight, "queue:#{name}")
|
|
139
|
+
end.freeze
|
|
140
|
+
@reserved_key = "#{identity}:reserved:#{processor_id}" if @reliable && identity
|
|
141
|
+
@metadata_key = "#{identity}:reservations" if @reserved_key
|
|
142
|
+
@identity = identity
|
|
143
|
+
@processor_id = processor_id
|
|
144
|
+
@paused = []
|
|
145
|
+
@paused_refresh_at = 0.0
|
|
146
|
+
@queue_index = 0
|
|
147
|
+
@check_reserved = !!@reserved_key
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def retrieve
|
|
151
|
+
if @check_reserved
|
|
152
|
+
@check_reserved = false
|
|
153
|
+
if (payload = @redis_pool.call("LINDEX", @reserved_key, -1))
|
|
154
|
+
return existing_work(payload)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
refresh_paused
|
|
159
|
+
queues = @queues.reject { |queue| @paused.include?(queue.delete_prefix("queue:")) }
|
|
160
|
+
return sleep(0.05) if queues.empty?
|
|
161
|
+
|
|
162
|
+
if @reserved_key
|
|
163
|
+
reserve_reliable(queues)
|
|
164
|
+
else
|
|
165
|
+
queues = queues.shuffle if @queue_mode == :random
|
|
166
|
+
result = @redis_pool.blocking_call(TIMEOUT, "BRPOP", *queues, TIMEOUT)
|
|
167
|
+
result && work(result.fetch(1), result.fetch(0).delete_prefix("queue:"))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def connection_failed!
|
|
172
|
+
@check_reserved = !!@reserved_key
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def work(payload, queue = nil, reservation_id: nil, attempt: nil)
|
|
178
|
+
job = JSON.parse(payload)
|
|
179
|
+
queue ||= job.fetch("queue")
|
|
180
|
+
reservation_id, attempt = register(job, queue) unless reservation_id
|
|
181
|
+
UnitOfWork.new(
|
|
182
|
+
queue: queue,
|
|
183
|
+
payload: payload,
|
|
184
|
+
job: job,
|
|
185
|
+
redis_pool: @redis_pool,
|
|
186
|
+
reserved_key: @reserved_key,
|
|
187
|
+
metadata_key: @metadata_key,
|
|
188
|
+
metadata_field: @processor_id.to_s,
|
|
189
|
+
reservation_id: reservation_id,
|
|
190
|
+
attempt: attempt,
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def existing_work(payload)
|
|
195
|
+
raw = @redis_pool.call("HGET", @metadata_key, @processor_id)
|
|
196
|
+
if raw
|
|
197
|
+
metadata = JSON.parse(raw)
|
|
198
|
+
job = JSON.parse(payload)
|
|
199
|
+
if metadata["job_id"] == job["jid"]
|
|
200
|
+
return UnitOfWork.new(
|
|
201
|
+
queue: job.fetch("queue"),
|
|
202
|
+
payload: payload,
|
|
203
|
+
job: job,
|
|
204
|
+
redis_pool: @redis_pool,
|
|
205
|
+
reserved_key: @reserved_key,
|
|
206
|
+
metadata_key: @metadata_key,
|
|
207
|
+
metadata_field: @processor_id.to_s,
|
|
208
|
+
reservation_id: metadata.fetch("reservation_id"),
|
|
209
|
+
attempt: Integer(metadata.fetch("attempt")),
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
work(payload)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def register(job, queue)
|
|
217
|
+
return [nil, nil] unless @reserved_key
|
|
218
|
+
|
|
219
|
+
reservation_id = SecureRandom.hex(12)
|
|
220
|
+
metadata = JSON.generate(
|
|
221
|
+
"job_id" => job["jid"],
|
|
222
|
+
"reservation_id" => reservation_id,
|
|
223
|
+
"process_id" => @identity,
|
|
224
|
+
"worker_id" => @processor_id,
|
|
225
|
+
"queue" => queue,
|
|
226
|
+
"reserved_at" => Time.now.to_f,
|
|
227
|
+
)
|
|
228
|
+
attempt = begin
|
|
229
|
+
@redis_pool.call(
|
|
230
|
+
"EVALSHA", REGISTER_SHA, 2,
|
|
231
|
+
@metadata_key, "solid-jobs:attempts",
|
|
232
|
+
job["jid"], @processor_id, metadata,
|
|
233
|
+
)
|
|
234
|
+
rescue SolidRedis::CommandError => error
|
|
235
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
236
|
+
|
|
237
|
+
@redis_pool.call(
|
|
238
|
+
"EVAL", REGISTER, 2,
|
|
239
|
+
@metadata_key, "solid-jobs:attempts",
|
|
240
|
+
job["jid"], @processor_id, metadata,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
[reservation_id, Integer(attempt)]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def next_queue(queues)
|
|
247
|
+
return queues.sample if @queue_mode == :random
|
|
248
|
+
|
|
249
|
+
queue = queues[@queue_index % queues.length]
|
|
250
|
+
@queue_index += 1
|
|
251
|
+
queue
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def reserve_reliable(queues)
|
|
255
|
+
if @queue_mode == :strict
|
|
256
|
+
queues.each do |source|
|
|
257
|
+
unit = reserve_now(source)
|
|
258
|
+
return unit if unit
|
|
259
|
+
end
|
|
260
|
+
source = queues.first
|
|
261
|
+
else
|
|
262
|
+
source = next_queue(queues)
|
|
263
|
+
unit = reserve_now(source)
|
|
264
|
+
return unit if unit
|
|
265
|
+
end
|
|
266
|
+
payload = @redis_pool.blocking_call(
|
|
267
|
+
TIMEOUT,
|
|
268
|
+
"BLMOVE", source, @reserved_key, "RIGHT", "LEFT", TIMEOUT,
|
|
269
|
+
)
|
|
270
|
+
payload && work(payload, source.delete_prefix("queue:"))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def reserve_now(source)
|
|
274
|
+
reservation_id = SecureRandom.hex(12)
|
|
275
|
+
reserved_at = Time.now.to_f
|
|
276
|
+
result = begin
|
|
277
|
+
@redis_pool.call(
|
|
278
|
+
"EVALSHA", RESERVE_SHA, 4,
|
|
279
|
+
source, @reserved_key, @metadata_key, "solid-jobs:attempts",
|
|
280
|
+
reservation_id, @identity, @processor_id, reserved_at,
|
|
281
|
+
)
|
|
282
|
+
rescue SolidRedis::CommandError => error
|
|
283
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
284
|
+
|
|
285
|
+
@redis_pool.call(
|
|
286
|
+
"EVAL", RESERVE, 4,
|
|
287
|
+
source, @reserved_key, @metadata_key, "solid-jobs:attempts",
|
|
288
|
+
reservation_id, @identity, @processor_id, reserved_at,
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
return unless result
|
|
292
|
+
|
|
293
|
+
payload, attempt = result
|
|
294
|
+
work(
|
|
295
|
+
payload,
|
|
296
|
+
source.delete_prefix("queue:"),
|
|
297
|
+
reservation_id: reservation_id,
|
|
298
|
+
attempt: Integer(attempt),
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def refresh_paused
|
|
303
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
304
|
+
return if now < @paused_refresh_at
|
|
305
|
+
|
|
306
|
+
@paused = Array(@redis_pool.call("SMEMBERS", "paused"))
|
|
307
|
+
@paused_refresh_at = now + 5
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|