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.
@@ -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