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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ class Error < StandardError; end
5
+ class InvalidJobError < ArgumentError; end
6
+ class InvalidArgumentError < InvalidJobError; end
7
+ class ConfigurationError < Error; end
8
+ class Shutdown < Error; end
9
+ 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