solid-jobs 0.1.3 → 0.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -4
  3. data/README.md +149 -118
  4. data/docs/reliability.md +73 -91
  5. data/exe/solid-jobs +2 -3
  6. data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +7 -8
  7. data/lib/solid_jobs/{config.rb → blueprint.rb} +51 -51
  8. data/lib/solid_jobs/catalog.rb +427 -0
  9. data/lib/solid_jobs/claim.rb +315 -0
  10. data/lib/solid_jobs/{server.rb → conductor.rb} +10 -10
  11. data/lib/solid_jobs/{cli.rb → console.rb} +12 -9
  12. data/lib/solid_jobs/{processor.rb → engine.rb} +33 -33
  13. data/lib/solid_jobs/errors.rb +1 -1
  14. data/lib/solid_jobs/executor.rb +34 -0
  15. data/lib/solid_jobs/failure_policy.rb +98 -0
  16. data/lib/solid_jobs/heartbeat.rb +15 -13
  17. data/lib/solid_jobs/integrity_check.rb +39 -36
  18. data/lib/solid_jobs/interceptor_registry.rb +57 -0
  19. data/lib/solid_jobs/keyspace.rb +42 -0
  20. data/lib/solid_jobs/{testing.rb → lab.rb} +19 -19
  21. data/lib/solid_jobs/publisher.rb +186 -0
  22. data/lib/solid_jobs/rails_adapter.rb +13 -0
  23. data/lib/solid_jobs/{rails.rb → railtie.rb} +1 -2
  24. data/lib/solid_jobs/recovery.rb +8 -7
  25. data/lib/solid_jobs/task.rb +147 -0
  26. data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
  27. data/lib/solid_jobs/utilities.rb +5 -11
  28. data/lib/solid_jobs/version.rb +1 -1
  29. data/lib/solid_jobs.rb +28 -27
  30. metadata +19 -18
  31. data/lib/solid_jobs/active_job.rb +0 -16
  32. data/lib/solid_jobs/api.rb +0 -457
  33. data/lib/solid_jobs/client.rb +0 -202
  34. data/lib/solid_jobs/fetch.rb +0 -310
  35. data/lib/solid_jobs/job.rb +0 -156
  36. data/lib/solid_jobs/middleware/chain.rb +0 -104
  37. data/lib/solid_jobs/retry_service.rb +0 -100
  38. 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
@@ -5,7 +5,7 @@ require "socket"
5
5
  require "timeout"
6
6
 
7
7
  module SolidJobs
8
- class Server
8
+ class Conductor
9
9
  # Extra time granted beyond shutdown_timeout before a component is
10
10
  # considered stuck, and how many times :stop is re-sent before giving up.
11
11
  STOP_GRACE = 5.0
@@ -25,7 +25,7 @@ module SolidJobs
25
25
  end
26
26
 
27
27
  def start
28
- raise Error, "Server is already running" if @started
28
+ raise Error, "Conductor is already running" if @started
29
29
 
30
30
  warn_ruby34_multi_ractor
31
31
  snapshot = Utilities.shareable_copy(
@@ -38,7 +38,7 @@ module SolidJobs
38
38
  local_config = heartbeat = nil
39
39
  begin
40
40
  started = SolidJobs::StartupBarrier.boot(ready) do
41
- local_config = SolidJobs::Config.from_ractor_snapshot(settings)
41
+ local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
42
42
  SolidJobs.use_config(local_config)
43
43
  heartbeat = SolidJobs::Heartbeat.new(
44
44
  local_config,
@@ -119,9 +119,9 @@ module SolidJobs
119
119
  local_config = processor = nil
120
120
  begin
121
121
  started = SolidJobs::StartupBarrier.boot(ready_port) do
122
- local_config = SolidJobs::Config.from_ractor_snapshot(settings)
122
+ local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
123
123
  SolidJobs.use_config(local_config)
124
- processor = SolidJobs::Processor.new(
124
+ processor = SolidJobs::Engine.new(
125
125
  local_config,
126
126
  identity: settings.fetch(:identity),
127
127
  processor_id: id,
@@ -161,9 +161,9 @@ module SolidJobs
161
161
  local_config = scheduler = nil
162
162
  begin
163
163
  started = SolidJobs::StartupBarrier.boot(ready) do
164
- local_config = SolidJobs::Config.from_ractor_snapshot(settings)
164
+ local_config = SolidJobs::Blueprint.from_ractor_snapshot(settings)
165
165
  SolidJobs.use_config(local_config)
166
- scheduler = SolidJobs::Scheduler.new(local_config)
166
+ scheduler = SolidJobs::Timer.new(local_config)
167
167
  local_config.redis_pool.call("PING")
168
168
  end
169
169
  next :aborted unless started
@@ -227,8 +227,8 @@ module SolidJobs
227
227
 
228
228
  RUBY34_MULTI_RACTOR_WARNING =
229
229
  "SolidJobs: Ruby %s can deadlock the whole VM on a Ractor GC barrier " \
230
- "under multi-Processor load (see docs/reliability.md). Run multi-Ractor " \
231
- "servers on Ruby >= 4.0, or use concurrency: 1 (one process per Processor)."
230
+ "under multi-Engine load (see docs/reliability.md). Run multi-Ractor " \
231
+ "servers on Ruby >= 4.0, or use concurrency: 1 (one process per Engine)."
232
232
 
233
233
  def warn_ruby34_multi_ractor
234
234
  return if RUBY_VERSION >= "4" || config.concurrency <= 1
@@ -265,7 +265,7 @@ module SolidJobs
265
265
  end
266
266
 
267
267
  def remote_signal
268
- config.redis_pool.call("RPOP", "#{identity}-signals")
268
+ config.redis_pool.call("RPOP", Keyspace.node_signals(identity))
269
269
  end
270
270
 
271
271
  end
@@ -4,11 +4,11 @@ require "optparse"
4
4
  require "yaml"
5
5
 
6
6
  module SolidJobs
7
- class CLI
7
+ class Console
8
8
  SIGNALS = %w[INT TERM TSTP TTIN INFO].freeze
9
9
 
10
10
  def initialize
11
- @options = {queues: []}
11
+ @options = {channels: []}
12
12
  end
13
13
 
14
14
  def run(arguments = ARGV)
@@ -16,7 +16,7 @@ module SolidJobs
16
16
  load_configuration
17
17
  load_application
18
18
  apply_options
19
- server = Server.new(config: SolidJobs.config).start
19
+ server = Conductor.new(config: SolidJobs.config).start
20
20
  read_io, write_io = IO.pipe
21
21
  handlers = install_signal_handlers(write_io)
22
22
  wait_for_shutdown(server, read_io)
@@ -38,8 +38,10 @@ module SolidJobs
38
38
  options.banner = "Usage: solid-jobs [options]"
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
- options.on("-c", "--concurrency N", Integer, "Processor Ractor count") { |value| @options[:concurrency] = value }
42
- options.on("-q", "--queue QUEUE", "Queue name or name,weight") { |value| @options[:queues] << queue(value) }
41
+ options.on("-c", "--concurrency N", Integer, "Engine Ractor count") { |value| @options[:concurrency] = value }
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
- @options[:queues] = Array(values["queues"]).map { |value| queue(value) } if @options[:queues].empty?
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.queues = @options[:queues] unless @options[:queues].empty?
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 queue(value)
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
-
@@ -3,11 +3,11 @@
3
3
  require "json"
4
4
 
5
5
  module SolidJobs
6
- class Processor
6
+ class Engine
7
7
  def initialize(config, identity: nil, processor_id: nil, heartbeat: nil)
8
8
  @config = config
9
- @fetcher = Fetch.new(config, identity: identity, processor_id: processor_id)
10
- @worker = Worker.new(redis_config: config.redis_config, config: config)
9
+ @claims = Claim.new(config, identity: identity, processor_id: processor_id)
10
+ @executor = Executor.new(redis_config: config.redis_config, config: config)
11
11
  @identity = identity
12
12
  @processor_id = processor_id
13
13
  @heartbeat = heartbeat
@@ -29,12 +29,12 @@ module SolidJobs
29
29
  next
30
30
  end
31
31
 
32
- unit = retrieve
33
- next unless unit
32
+ claim = retrieve
33
+ next unless claim
34
34
 
35
35
  receive_commands(control)
36
36
  if @stopping || @quiet
37
- requeue(unit)
37
+ requeue(claim)
38
38
  break if @stopping
39
39
 
40
40
  next
@@ -42,19 +42,19 @@ module SolidJobs
42
42
 
43
43
  begin
44
44
  @state_mutex.synchronize { @busy = true }
45
- payload = unit.job
46
- register_work(unit, payload)
45
+ envelope = claim.envelope
46
+ register_work(claim, envelope)
47
47
  begin
48
- @worker.perform(payload)
49
- rescue Shutdown
50
- requeue(unit)
48
+ @executor.execute(envelope)
49
+ rescue ExecutionHalt
50
+ requeue(claim)
51
51
  break
52
52
  rescue StandardError => error
53
- RetryService.call(config: @config, payload: payload, error: error)
54
- acknowledge(unit)
53
+ FailurePolicy.call(config: @config, payload: envelope, error: error)
54
+ complete(claim)
55
55
  failed += 1
56
56
  else
57
- processed += 1 if acknowledge(unit)
57
+ processed += 1 if complete(claim)
58
58
  end
59
59
  ensure
60
60
  @state_mutex.synchronize { @busy = false }
@@ -73,55 +73,55 @@ module SolidJobs
73
73
 
74
74
  def interrupt_current
75
75
  runner = @state_mutex.synchronize { @runner if @busy }
76
- runner&.raise(Shutdown, "SolidJobs shutdown timeout exceeded")
76
+ runner&.raise(ExecutionHalt, "SolidJobs shutdown timeout exceeded")
77
77
  end
78
78
 
79
79
  private
80
80
 
81
- def acknowledge(unit)
82
- acknowledged = unit.acknowledge
83
- unless acknowledged
81
+ def complete(claim)
82
+ completed = claim.complete
83
+ unless completed
84
84
  @config.logger.warn(
85
- "Reservation fencing rejected stale ACK: #{unit.reservation_id}",
85
+ "Claim fencing rejected stale completion: #{claim.claim_token}",
86
86
  )
87
87
  end
88
- acknowledged
88
+ completed
89
89
  rescue SolidRedis::ConnectionError => error
90
90
  @fetcher.connection_failed!
91
91
  @config.logger.warn(
92
- "Redis ACK failed; reserved job will be replayed: #{error.class}: #{error.message}",
92
+ "Redis completion failed; claimed task will be replayed: #{error.class}: #{error.message}",
93
93
  )
94
94
  false
95
95
  end
96
96
 
97
- def requeue(unit)
98
- requeued = unit.requeue
97
+ def requeue(claim)
98
+ requeued = claim.requeue
99
99
  unless requeued
100
100
  @config.logger.warn(
101
- "Reservation fencing rejected stale requeue: #{unit.reservation_id}",
101
+ "Claim fencing rejected stale requeue: #{claim.claim_token}",
102
102
  )
103
103
  end
104
104
  requeued
105
105
  end
106
106
 
107
107
  def retrieve
108
- @fetcher.retrieve
108
+ @claims.next
109
109
  rescue SolidRedis::ConnectionError => error
110
- @fetcher.connection_failed!
111
- @config.logger.warn("Redis fetch failed, retrying: #{error.class}: #{error.message}")
110
+ @claims.connection_failed!
111
+ @config.logger.warn("Redis claim failed, retrying: #{error.class}: #{error.message}")
112
112
  sleep 0.1
113
113
  nil
114
114
  end
115
115
 
116
- def register_work(unit, payload)
116
+ def register_work(claim, envelope)
117
117
  return unless @identity
118
118
 
119
119
  work = JSON.generate(
120
- "queue" => unit.queue,
121
- "payload" => payload,
122
- "run_at" => Time.now.to_f,
123
- "reservation_id" => unit.reservation_id,
124
- "attempt" => unit.attempt,
120
+ "channel" => claim.channel,
121
+ "envelope" => envelope,
122
+ "started_at" => Time.now.to_f,
123
+ "claim_token" => claim.claim_token,
124
+ "attempt" => claim.attempt,
125
125
  )
126
126
  send_heartbeat(:work, @processor_id, work)
127
127
  end
@@ -5,5 +5,5 @@ module SolidJobs
5
5
  class InvalidJobError < ArgumentError; end
6
6
  class InvalidArgumentError < InvalidJobError; end
7
7
  class ConfigurationError < Error; end
8
- class Shutdown < Error; end
8
+ class ExecutionHalt < Error; end
9
9
  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 || Blueprint.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.execute_task(*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