solid-jobs 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +392 -160
- data/lib/solid_jobs/backends/base.rb +58 -0
- data/lib/solid_jobs/backends/redis.rb +80 -0
- data/lib/solid_jobs/backends/solid_engine.rb +288 -0
- data/lib/solid_jobs/backends/worker.rb +166 -0
- data/lib/solid_jobs/blueprint.rb +113 -2
- data/lib/solid_jobs/conductor.rb +115 -0
- data/lib/solid_jobs/publisher.rb +16 -0
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +4 -0
- metadata +5 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
module Backends
|
|
5
|
+
class NotSupported < Error; end
|
|
6
|
+
|
|
7
|
+
# Contract shared by every storage/execution backend. SolidJobs owns the
|
|
8
|
+
# job API; adapters translate these operations to their engine.
|
|
9
|
+
#
|
|
10
|
+
# A "claim" is whatever #claim returns (an opaque reservation holder that
|
|
11
|
+
# exposes #id, #channel, #envelope and #attempt). It carries the
|
|
12
|
+
# reservation token / fencing token, so #ack and #retry_job take the
|
|
13
|
+
# claim instead of a bare job id.
|
|
14
|
+
#
|
|
15
|
+
# Delivery is at-least-once on every backend. Differences in guarantees
|
|
16
|
+
# are exposed through #capabilities and #supports?; operations a backend
|
|
17
|
+
# cannot honor raise NotSupported instead of silently degrading.
|
|
18
|
+
class Base
|
|
19
|
+
CAPABILITIES = %i[
|
|
20
|
+
scheduling fencing_tokens lease_extension get_job cancel deduplication
|
|
21
|
+
crash_recovery server_side_retries
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Persists one job. Returns { id:, duplicate: }.
|
|
25
|
+
def enqueue(_envelope, **_options) = raise(NotImplementedError)
|
|
26
|
+
|
|
27
|
+
# Persists a job that becomes ready at `at` (Unix seconds).
|
|
28
|
+
def schedule(_envelope, at:, **_options) = raise(NotImplementedError)
|
|
29
|
+
|
|
30
|
+
def enqueue_many(envelopes)
|
|
31
|
+
envelopes.each { |envelope| envelope.key?("run_at") ? schedule(envelope, at: envelope["run_at"]) : enqueue(envelope) }
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reserves the next ready job of `channel`, or returns nil.
|
|
36
|
+
def claim(_channel) = raise(NotImplementedError)
|
|
37
|
+
|
|
38
|
+
def ack(_claim) = raise(NotImplementedError)
|
|
39
|
+
|
|
40
|
+
# Re-queues a claimed job after `delay` seconds (nil: backend policy).
|
|
41
|
+
def retry_job(_claim, delay: nil) = raise(NotImplementedError)
|
|
42
|
+
|
|
43
|
+
# Moves a claimed job to the dead state (no further retries).
|
|
44
|
+
def fail_job(_claim) = raise(NotSupported, "#{self.class.name} cannot fail jobs natively")
|
|
45
|
+
|
|
46
|
+
def get_job(_job_id) = raise(NotSupported, "#{self.class.name} cannot look up jobs by id")
|
|
47
|
+
|
|
48
|
+
# Reclaims jobs owned by dead workers; returns how many were recovered.
|
|
49
|
+
def recover(**_options) = 0
|
|
50
|
+
|
|
51
|
+
def capabilities = []
|
|
52
|
+
|
|
53
|
+
def supports?(capability) = capabilities.include?(capability)
|
|
54
|
+
|
|
55
|
+
def close = self
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
module Backends
|
|
7
|
+
# SolidRedis adapter: the original Redis-backed storage, behind the
|
|
8
|
+
# backend contract. Publishing goes through the same pipeline as before.
|
|
9
|
+
class Redis < Base
|
|
10
|
+
CAPABILITIES = %i[scheduling crash_recovery deduplication].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# Pass `url:` through the backend spec to target a different Redis
|
|
15
|
+
# server than the default configuration.
|
|
16
|
+
def initialize(config:, owned: false)
|
|
17
|
+
super()
|
|
18
|
+
@config = config
|
|
19
|
+
@owned = owned
|
|
20
|
+
@claimers = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def capabilities = CAPABILITIES
|
|
24
|
+
|
|
25
|
+
def enqueue(envelope, **_options)
|
|
26
|
+
envelope = Utilities.stringify_hash_keys(envelope)
|
|
27
|
+
envelope["id"] ||= SecureRandom.uuid
|
|
28
|
+
enqueue_many([envelope])
|
|
29
|
+
{ id: envelope["id"], duplicate: false }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def schedule(envelope, at:, **options)
|
|
33
|
+
enqueue(Utilities.stringify_hash_keys(envelope).merge("run_at" => Float(at)), **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enqueue_many(envelopes)
|
|
37
|
+
publisher = Publisher.new(config: @config)
|
|
38
|
+
barriers = publisher.persist_redis(envelopes)
|
|
39
|
+
PublicationBarrier.release(@config.redis_pool, barriers)
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def claim(channel)
|
|
44
|
+
claimer(channel).next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ack(claim)
|
|
48
|
+
claim.complete
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def retry_job(claim, delay: nil)
|
|
52
|
+
delay ||= 0
|
|
53
|
+
payload = claim.envelope.merge("channel" => claim.channel)
|
|
54
|
+
@config.redis_pool.call("ZADD", Keyspace::RETRIES, Time.now.to_f + delay, JSON.generate(payload))
|
|
55
|
+
claim.complete
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def recover(**)
|
|
59
|
+
Recovery.call(config: @config)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def close
|
|
63
|
+
@claimers.each_value { |(cfg, _)| cfg.close }
|
|
64
|
+
@claimers.clear
|
|
65
|
+
@config.close if @owned
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def claimer(channel)
|
|
72
|
+
@claimers[channel.to_s] ||= begin
|
|
73
|
+
scoped = Blueprint.new(redis: @config.redis_config, concurrency: 1, channels: [channel.to_s])
|
|
74
|
+
[scoped, Claim.new(scoped, identity: @config.identity, processor_id: "backend-#{channel}")]
|
|
75
|
+
end
|
|
76
|
+
@claimers[channel.to_s].last
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module SolidJobs
|
|
10
|
+
module Backends
|
|
11
|
+
# Native client and backend adapter for the SolidEngine storage engine
|
|
12
|
+
# (binary protocol v1 over Unix sockets or TCP).
|
|
13
|
+
#
|
|
14
|
+
# SolidJobs.config.backend = :solid_engine
|
|
15
|
+
# SolidJobs.config.url = "unix:///var/run/solidengine.sock"
|
|
16
|
+
class SolidEngine < Base
|
|
17
|
+
MAGIC = 0x474E4553
|
|
18
|
+
VERSION = 1
|
|
19
|
+
HEADER_LEN = 20
|
|
20
|
+
RESPONSE_BIT = 0x80
|
|
21
|
+
ERROR = 0xFF
|
|
22
|
+
DELAY_FROM_POLICY = 0xFFFF_FFFF_FFFF_FFFF
|
|
23
|
+
|
|
24
|
+
PING = 1
|
|
25
|
+
PUSH_JOB = 2
|
|
26
|
+
CLAIM_NEXT_JOB = 3
|
|
27
|
+
ACK_JOB = 4
|
|
28
|
+
NACK_JOB = 5
|
|
29
|
+
EXTEND_LEASE = 6
|
|
30
|
+
GET_JOB = 7
|
|
31
|
+
CANCEL_JOB = 8
|
|
32
|
+
WAIT_FOR_JOB = 9
|
|
33
|
+
GET_QUEUE_STATS = 10
|
|
34
|
+
|
|
35
|
+
ERROR_CODES = {
|
|
36
|
+
1 => :bad_request, 2 => :not_found, 3 => :stale_reservation, 4 => :invalid_state,
|
|
37
|
+
5 => :overloaded, 6 => :payload_too_large, 7 => :internal, 8 => :shutting_down,
|
|
38
|
+
}.freeze
|
|
39
|
+
JOB_STATES = %i[ready claimed completed scheduled dead cancelled].freeze
|
|
40
|
+
STATS_FIELDS = %i[
|
|
41
|
+
ready claimed scheduled completed dead cancelled pushed claims acks nacks lease_expirations
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
class Error < StandardError
|
|
45
|
+
attr_reader :code
|
|
46
|
+
|
|
47
|
+
def initialize(code, message)
|
|
48
|
+
@code = code
|
|
49
|
+
super("#{code}: #{message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class ProtocolError < StandardError; end
|
|
54
|
+
|
|
55
|
+
CAPABILITIES = %i[
|
|
56
|
+
scheduling fencing_tokens lease_extension get_job cancel deduplication
|
|
57
|
+
crash_recovery server_side_retries
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
Lease = Struct.new(:job_id, :reservation_id, :fencing_token, :lease_deadline, :attempts, :payload, :envelope,
|
|
61
|
+
keyword_init: true) do
|
|
62
|
+
def id = envelope["id"]
|
|
63
|
+
def channel = envelope["channel"]
|
|
64
|
+
def attempt = attempts
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :url, :lease_ms
|
|
68
|
+
|
|
69
|
+
def initialize(url:, channel_ids: {}, lease_ms: 30_000, timeout: 5.0)
|
|
70
|
+
super()
|
|
71
|
+
@url = URI(url)
|
|
72
|
+
@channel_ids = channel_ids.transform_keys(&:to_s)
|
|
73
|
+
@lease_ms = Integer(lease_ms)
|
|
74
|
+
@timeout = timeout
|
|
75
|
+
@mutex = Mutex.new
|
|
76
|
+
@socket = nil
|
|
77
|
+
@request_id = 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ping
|
|
81
|
+
call(PING)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def enqueue(envelope, run_at: nil, priority: 0, max_attempts: 0, partition_key: "")
|
|
86
|
+
envelope = envelope.transform_keys(&:to_s)
|
|
87
|
+
id = (envelope["id"] ||= SecureRandom.uuid)
|
|
88
|
+
run_at ||= envelope.delete("run_at")
|
|
89
|
+
delay_ms = run_at ? [((run_at.to_f - Time.now.to_f) * 1000).ceil, 0].max : 0
|
|
90
|
+
body = pack_u128(uuid_to_int(id))
|
|
91
|
+
body << [channel_id(envelope.fetch("channel", "default")), priority, max_attempts, delay_ms].pack("S<CL<Q<")
|
|
92
|
+
body << pack_bytes(partition_key.to_s) << pack_bytes(JSON.generate(envelope))
|
|
93
|
+
_type, reply = call(PUSH_JOB, body)
|
|
94
|
+
{ id: id, duplicate: reply.getbyte(16) != 0 }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def schedule(envelope, at: nil, run_at: nil, **options)
|
|
98
|
+
enqueue(envelope, run_at: at || run_at, **options)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def enqueue_many(envelopes)
|
|
102
|
+
envelopes.each do |envelope|
|
|
103
|
+
failures = envelope["max_failures"]
|
|
104
|
+
bounded = failures && !envelope["retry_within"]
|
|
105
|
+
enqueue(envelope, max_attempts: bounded ? Integer(failures) + 1 : 0)
|
|
106
|
+
end
|
|
107
|
+
[]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def capabilities = CAPABILITIES
|
|
111
|
+
|
|
112
|
+
def claim(channel = "default", lease_ms: @lease_ms, wait_ms: 0)
|
|
113
|
+
body = [channel_id(channel), lease_ms, wait_ms].pack("S<L<L<")
|
|
114
|
+
_type, reply = call(CLAIM_NEXT_JOB, body, read_timeout: (wait_ms / 1000.0) + @timeout)
|
|
115
|
+
return nil if reply.getbyte(0) == 0
|
|
116
|
+
|
|
117
|
+
job_id = unpack_u128(reply, 1)
|
|
118
|
+
reservation_id, fencing, deadline, attempts, length = reply.byteslice(17, 32).unpack("Q<Q<Q<L<L<")
|
|
119
|
+
payload = reply.byteslice(49, length)
|
|
120
|
+
Lease.new(job_id: job_id, reservation_id: reservation_id, fencing_token: fencing,
|
|
121
|
+
lease_deadline: deadline, attempts: attempts, payload: payload,
|
|
122
|
+
envelope: JSON.parse(payload))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ack(lease)
|
|
126
|
+
call(ACK_JOB, reservation(lease))
|
|
127
|
+
true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Retries the job; delay: nil applies the engine's backoff policy.
|
|
131
|
+
def nack(lease, delay: nil, fail: false)
|
|
132
|
+
flags = fail ? 1 : 0
|
|
133
|
+
delay_ms = if fail then 0 elsif delay.nil? then DELAY_FROM_POLICY else (delay * 1000).ceil end
|
|
134
|
+
call(NACK_JOB, reservation(lease) + [flags, delay_ms].pack("CQ<"))
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fail_job(lease)
|
|
139
|
+
nack(lease, fail: true)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def retry_job(lease, delay: nil)
|
|
143
|
+
nack(lease, delay: delay)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The engine reclaims expired leases itself; nothing to do client-side.
|
|
147
|
+
def recover(**) = 0
|
|
148
|
+
|
|
149
|
+
def extend_lease(lease, lease_ms: @lease_ms)
|
|
150
|
+
_type, reply = call(EXTEND_LEASE, reservation(lease) + [lease_ms].pack("L<"))
|
|
151
|
+
lease.lease_deadline = reply.unpack1("Q<")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cancel(job_id)
|
|
155
|
+
call(CANCEL_JOB, pack_u128(uuid_to_int(job_id)))
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def get_job(job_id) = job(job_id)
|
|
160
|
+
|
|
161
|
+
def job(job_id)
|
|
162
|
+
_type, reply = call(GET_JOB, pack_u128(uuid_to_int(job_id)))
|
|
163
|
+
channel, state, priority, attempts, max_attempts, fencing, deadline, scheduled_at, created_at, length =
|
|
164
|
+
reply.byteslice(16, reply.bytesize - 16).unpack("S<CCL<L<Q<Q<Q<Q<L<")
|
|
165
|
+
{ channel_id: channel, state: JOB_STATES.fetch(state), priority: priority, attempts: attempts,
|
|
166
|
+
max_attempts: max_attempts, fencing_token: fencing, lease_deadline: deadline,
|
|
167
|
+
scheduled_at: scheduled_at, created_at: created_at, payload_length: length }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def stats(channel: nil)
|
|
171
|
+
body = channel ? [1, channel_id(channel)].pack("CS<") : [0, 0].pack("CS<")
|
|
172
|
+
_type, reply = call(GET_QUEUE_STATS, body)
|
|
173
|
+
STATS_FIELDS.zip(reply.unpack("Q<11")).to_h
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def close
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
@socket&.close
|
|
179
|
+
@socket = nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def channel_id(name)
|
|
184
|
+
@channel_ids.fetch(name.to_s) { (Zlib.crc32(name.to_s) % 65_535) + 1 }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def uuid_to_int(id)
|
|
188
|
+
hex = id.to_s.delete("-")
|
|
189
|
+
raise ArgumentError, "job id must be a UUID: #{id.inspect}" unless hex.match?(/\A\h{32}\z/)
|
|
190
|
+
|
|
191
|
+
hex.to_i(16)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def int_to_uuid(value)
|
|
195
|
+
hex = value.to_s(16).rjust(32, "0")
|
|
196
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def reservation(lease)
|
|
202
|
+
pack_u128(lease.job_id) + [lease.reservation_id, lease.fencing_token].pack("Q<Q<")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def pack_u128(value)
|
|
206
|
+
[value & 0xFFFF_FFFF_FFFF_FFFF, value >> 64].pack("Q<Q<")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def unpack_u128(data, offset)
|
|
210
|
+
low, high = data.byteslice(offset, 16).unpack("Q<Q<")
|
|
211
|
+
(high << 64) | low
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def pack_bytes(string)
|
|
215
|
+
string = string.b
|
|
216
|
+
[string.bytesize].pack("L<") + string
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def call(type, body = "".b, read_timeout: @timeout)
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
attempts = 0
|
|
222
|
+
begin
|
|
223
|
+
socket = (@socket ||= connect)
|
|
224
|
+
@request_id += 1
|
|
225
|
+
write_frame(socket, type, @request_id, body.b)
|
|
226
|
+
read_response(socket, type, @request_id, read_timeout)
|
|
227
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, EOFError, IOError
|
|
228
|
+
@socket&.close
|
|
229
|
+
@socket = nil
|
|
230
|
+
attempts += 1
|
|
231
|
+
# Only idempotent reads are replayed after a lost connection.
|
|
232
|
+
retry if attempts < 2 && [PING, GET_JOB, GET_QUEUE_STATS].include?(type)
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def connect
|
|
239
|
+
case @url.scheme
|
|
240
|
+
when "unix" then UNIXSocket.new(@url.path)
|
|
241
|
+
when "tcp" then TCPSocket.new(@url.host, @url.port).tap { |s| s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) }
|
|
242
|
+
else raise ArgumentError, "unsupported SolidEngine url scheme: #{@url.scheme.inspect}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def write_frame(socket, type, request_id, body)
|
|
247
|
+
header = [MAGIC, VERSION, type, 0, request_id, body.bytesize].pack("L<CCS<Q<L<")
|
|
248
|
+
crc = Zlib.crc32(body, Zlib.crc32(header))
|
|
249
|
+
socket.write(header + body + [crc].pack("L<"))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def read_response(socket, request_type, request_id, timeout)
|
|
253
|
+
header = read_exact(socket, HEADER_LEN, timeout)
|
|
254
|
+
magic, version, type, _flags, id, length = header.unpack("L<CCS<Q<L<")
|
|
255
|
+
raise ProtocolError, "bad magic" unless magic == MAGIC
|
|
256
|
+
raise ProtocolError, "unsupported version #{version}" unless version == VERSION
|
|
257
|
+
|
|
258
|
+
body = read_exact(socket, length, timeout)
|
|
259
|
+
crc = read_exact(socket, 4, timeout).unpack1("L<")
|
|
260
|
+
raise ProtocolError, "checksum mismatch" unless Zlib.crc32(body, Zlib.crc32(header)) == crc
|
|
261
|
+
raise ProtocolError, "unexpected request id #{id}" unless id == request_id
|
|
262
|
+
|
|
263
|
+
if type == ERROR
|
|
264
|
+
code = ERROR_CODES.fetch(body.unpack1("S<"), :internal)
|
|
265
|
+
raise Error.new(code, body.byteslice(6, body.byteslice(2, 4).unpack1("L<")))
|
|
266
|
+
end
|
|
267
|
+
raise ProtocolError, "unexpected response type #{type}" unless type == (request_type | RESPONSE_BIT)
|
|
268
|
+
|
|
269
|
+
[type, body]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def read_exact(socket, count, timeout)
|
|
273
|
+
data = "".b
|
|
274
|
+
while data.bytesize < count
|
|
275
|
+
unless socket.wait_readable(timeout)
|
|
276
|
+
@socket&.close
|
|
277
|
+
@socket = nil
|
|
278
|
+
raise ProtocolError, "read timeout"
|
|
279
|
+
end
|
|
280
|
+
chunk = socket.read_nonblock(count - data.bytesize, exception: false)
|
|
281
|
+
raise EOFError if chunk.nil?
|
|
282
|
+
data << chunk unless chunk == :wait_readable
|
|
283
|
+
end
|
|
284
|
+
data
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
module Backends
|
|
7
|
+
# Claim/execute/ack loop for channels served by a non-Redis backend
|
|
8
|
+
# (SolidEngine). One instance per thread; it owns its backend connection.
|
|
9
|
+
class Worker
|
|
10
|
+
IDLE_SLEEP = 0.02
|
|
11
|
+
|
|
12
|
+
attr_reader :processed, :failed
|
|
13
|
+
|
|
14
|
+
def initialize(config:, backend_name:, channels:, processor_id: 0)
|
|
15
|
+
@config = config
|
|
16
|
+
@backend = config.build_backend(backend_name)
|
|
17
|
+
@channels = channels.flat_map { |name, weight| Array.new(weight, name) }
|
|
18
|
+
@processor_id = processor_id
|
|
19
|
+
@executor = Executor.new(redis_config: config.redis_config, config: config)
|
|
20
|
+
@processed = 0
|
|
21
|
+
@failed = 0
|
|
22
|
+
@cursor = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(stop:, paused: -> { false })
|
|
26
|
+
until stop.call
|
|
27
|
+
if paused.call
|
|
28
|
+
sleep 0.05
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
claim = next_claim
|
|
33
|
+
if claim
|
|
34
|
+
perform(claim)
|
|
35
|
+
else
|
|
36
|
+
sleep IDLE_SLEEP
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
self
|
|
40
|
+
ensure
|
|
41
|
+
@backend.close
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def next_claim
|
|
47
|
+
@channels.length.times do
|
|
48
|
+
channel = @channels[@cursor % @channels.length]
|
|
49
|
+
@cursor += 1
|
|
50
|
+
claim = @backend.claim(channel)
|
|
51
|
+
return claim if claim
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
rescue SystemCallError, IOError, EOFError, Backends::SolidEngine::ProtocolError => error
|
|
55
|
+
@config.logger.warn("SolidJobs backend claim failed, retrying: #{error.class}: #{error.message}")
|
|
56
|
+
sleep 0.1
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def perform(claim)
|
|
61
|
+
envelope = claim.envelope
|
|
62
|
+
attributes = {reservation_id: claim.reservation_id, attempt: claim.attempt,
|
|
63
|
+
**Instrumentation.worker_attributes(@processor_id)}
|
|
64
|
+
Instrumentation.emit(@config, :reserved, envelope, **attributes)
|
|
65
|
+
Instrumentation.emit(@config, :started, envelope, **attributes)
|
|
66
|
+
started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
67
|
+
keepalive = start_keepalive(claim)
|
|
68
|
+
begin
|
|
69
|
+
@executor.execute(envelope)
|
|
70
|
+
rescue ExecutionHalt
|
|
71
|
+
raise
|
|
72
|
+
rescue StandardError => error
|
|
73
|
+
duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started
|
|
74
|
+
Instrumentation.emit(@config, :failed, envelope, **attributes, duration: duration,
|
|
75
|
+
error_class: error.class.name, error_message: error.message.to_s)
|
|
76
|
+
handle_failure(claim, envelope, error, attributes)
|
|
77
|
+
@failed += 1
|
|
78
|
+
else
|
|
79
|
+
duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started
|
|
80
|
+
Instrumentation.emit(@config, :completed, envelope, **attributes, duration: duration)
|
|
81
|
+
settle(claim) { @backend.ack(claim) }
|
|
82
|
+
Instrumentation.emit(@config, :acknowledged, envelope, **attributes)
|
|
83
|
+
@processed += 1
|
|
84
|
+
end
|
|
85
|
+
ensure
|
|
86
|
+
stop_keepalive(keepalive)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Mirrors FailurePolicy: retry with backoff while allowed, then discard.
|
|
90
|
+
def handle_failure(claim, envelope, error, attributes)
|
|
91
|
+
failure_count = claim.attempt
|
|
92
|
+
task = resolve_task(envelope)
|
|
93
|
+
decision = task.respond_to?(:retry_delay_callback) ? task.retry_delay_callback(failure_count, error, envelope) : nil
|
|
94
|
+
if retry_allowed?(envelope, claim.attempt) && !%i[drop archive].include?(decision)
|
|
95
|
+
delay = decision.is_a?(Numeric) ? decision : backoff(failure_count)
|
|
96
|
+
settle(claim) { @backend.retry_job(claim, delay: delay) }
|
|
97
|
+
Instrumentation.emit(@config, :retry_scheduled, envelope, **attributes,
|
|
98
|
+
error_class: error.class.name, error_message: error.message.to_s)
|
|
99
|
+
else
|
|
100
|
+
task.final_failure_callback(envelope, error) if decision != :drop && task.respond_to?(:final_failure_callback)
|
|
101
|
+
settle(claim) { @backend.fail_job(claim) }
|
|
102
|
+
Instrumentation.emit(@config, :dead, envelope, **attributes,
|
|
103
|
+
error_class: error.class.name, error_message: error.message.to_s)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def retry_allowed?(envelope, attempt)
|
|
108
|
+
if envelope["retry_within"]
|
|
109
|
+
created = Float(envelope.fetch("created_ms", Utilities.realtime_milliseconds)) / 1_000
|
|
110
|
+
Time.now.to_f - created < Float(envelope["retry_within"])
|
|
111
|
+
else
|
|
112
|
+
attempt <= Integer(envelope.fetch("max_failures", 0))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def backoff(failure_count)
|
|
117
|
+
ceiling = [Float(@config.retry_base_delay) * (2**(failure_count - 1)), Float(@config.retry_max_delay)].min
|
|
118
|
+
(ceiling / 2) + (rand * ceiling / 2)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_task(envelope)
|
|
122
|
+
Utilities.constantize(envelope.fetch("task"))
|
|
123
|
+
rescue NameError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A stale reservation means the job was reclaimed: fencing did its job.
|
|
128
|
+
def settle(claim)
|
|
129
|
+
yield
|
|
130
|
+
rescue Backends::SolidEngine::Error => error
|
|
131
|
+
raise unless error.code == :stale_reservation
|
|
132
|
+
|
|
133
|
+
@config.logger.warn("Claim fencing rejected stale settlement: #{claim.id}")
|
|
134
|
+
rescue SystemCallError, IOError, EOFError, Backends::SolidEngine::ProtocolError => error
|
|
135
|
+
# Settlement unknown: leave the lease to expire so the job is redelivered (at-least-once).
|
|
136
|
+
@config.logger.warn("SolidJobs settlement failed, job will be redelivered: #{claim.id} #{error.class}: #{error.message}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def start_keepalive(claim)
|
|
140
|
+
return unless @backend.supports?(:lease_extension)
|
|
141
|
+
|
|
142
|
+
interval = @backend.lease_ms / 3000.0
|
|
143
|
+
done = Queue.new
|
|
144
|
+
thread = Thread.new do
|
|
145
|
+
until done.pop(timeout: interval)
|
|
146
|
+
begin
|
|
147
|
+
@backend.extend_lease(claim)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
[thread, done]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Joined rather than killed: a killed thread could leave an unread reply on the socket.
|
|
157
|
+
def stop_keepalive(keepalive)
|
|
158
|
+
return unless keepalive
|
|
159
|
+
|
|
160
|
+
thread, done = keepalive
|
|
161
|
+
done << true
|
|
162
|
+
thread.join
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|