solid-jobs 0.1.2 → 0.2.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,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
@@ -8,7 +8,7 @@ module SolidJobs
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)
@@ -39,7 +39,9 @@ module SolidJobs
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
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) }
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
-
@@ -4,35 +4,35 @@ require "logger"
4
4
 
5
5
  module SolidJobs
6
6
  class Config
7
- DEFAULT_JOB_OPTIONS = {
8
- "queue" => "default",
9
- "retry" => true,
7
+ DEFAULT_TASK_OPTIONS = {
8
+ "channel" => "default",
9
+ "max_failures" => 25,
10
10
  }.freeze
11
11
 
12
- attr_accessor :concurrency, :dead_max_jobs, :dead_timeout, :logger,
12
+ attr_accessor :concurrency, :discarded_limit, :discarded_retention, :logger,
13
13
  :on_complex_arguments, :poll_interval_average, :shutdown_timeout,
14
14
  :reliable_fetch, :retry_base_delay, :retry_max_delay
15
- attr_reader :client_middleware, :default_job_options, :error_handlers,
16
- :redis_config, :server_middleware
15
+ attr_reader :default_task_options, :error_handlers, :execute_interceptors,
16
+ :publish_interceptors, :redis_config
17
17
 
18
- def initialize(redis: nil, concurrency: 5, queues: ["default"])
18
+ def initialize(redis: nil, concurrency: 5, channels: ["default"])
19
19
  @redis_config = redis || SolidRedis.config(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
20
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
21
+ @channels = normalize_channels(channels)
22
+ @default_task_options = DEFAULT_TASK_OPTIONS
23
+ @publish_interceptors = InterceptorRegistry.new
24
+ @execute_interceptors = InterceptorRegistry.new
25
25
  @error_handlers = []
26
26
  @lifecycle_callbacks = Hash.new { |hash, event| hash[event] = [] }
27
27
  @on_complex_arguments = :raise
28
28
  @poll_interval_average = 5.0
29
29
  @shutdown_timeout = 25.0
30
- @queue_mode = :weighted
30
+ @channel_order = :weighted
31
31
  @reliable_fetch = true
32
32
  @retry_base_delay = 15.0
33
33
  @retry_max_delay = 3_600.0
34
- @dead_max_jobs = 10_000
35
- @dead_timeout = 180 * 24 * 60 * 60
34
+ @discarded_limit = 10_000
35
+ @discarded_retention = 180 * 24 * 60 * 60
36
36
  @logger = Logger.new($stdout)
37
37
  @redis_pool = nil
38
38
  end
@@ -50,42 +50,42 @@ module SolidJobs
50
50
  redis_pool.with { |connection| yield connection }
51
51
  end
52
52
 
53
- def queues
54
- @queues.map(&:first)
53
+ def channels
54
+ @channels.map(&:first)
55
55
  end
56
56
 
57
- def queues=(values)
58
- @queues = normalize_queues(values)
57
+ def channels=(values)
58
+ @channels = normalize_channels(values)
59
59
  end
60
60
 
61
- def queue_entries
62
- @queues.dup
61
+ def channel_entries
62
+ @channels.dup
63
63
  end
64
64
 
65
- def queue_mode
66
- @queue_mode
65
+ def channel_order
66
+ @channel_order
67
67
  end
68
68
 
69
- def queue_mode=(mode)
69
+ def channel_order=(mode)
70
70
  mode = mode.to_sym
71
- unless %i[strict weighted random].include?(mode)
72
- raise ArgumentError, "queue_mode must be :strict, :weighted, or :random"
71
+ unless %i[priority weighted shuffle].include?(mode)
72
+ raise ArgumentError, "channel_order must be :priority, :weighted, or :shuffle"
73
73
  end
74
74
 
75
- @queue_mode = mode
75
+ @channel_order = mode
76
76
  end
77
77
 
78
- def strict
79
- queue_mode == :strict
78
+ def prioritized?
79
+ channel_order == :priority
80
80
  end
81
81
 
82
- def strict=(value)
83
- self.queue_mode = value ? :strict : :weighted
82
+ def prioritized=(value)
83
+ self.channel_order = value ? :priority : :weighted
84
84
  end
85
85
 
86
- def default_job_options=(options)
87
- @default_job_options = Utilities.shareable_copy(
88
- DEFAULT_JOB_OPTIONS.merge(Utilities.stringify_keys(options)),
86
+ def default_task_options=(options)
87
+ @default_task_options = Utilities.shareable_copy(
88
+ DEFAULT_TASK_OPTIONS.merge(Utilities.stringify_keys(options)),
89
89
  )
90
90
  end
91
91
 
@@ -111,26 +111,26 @@ module SolidJobs
111
111
  end
112
112
 
113
113
  def inspect
114
- "#<#{self.class.name} concurrency=#{concurrency} queues=#{queues.inspect}>"
114
+ "#<#{self.class.name} concurrency=#{concurrency} channels=#{channels.inspect}>"
115
115
  end
116
116
 
117
117
  def ractor_snapshot
118
118
  Utilities.shareable_copy(
119
119
  redis_config: redis_config,
120
120
  concurrency: concurrency,
121
- queues: queue_entries,
122
- queue_mode: queue_mode,
121
+ channels: channel_entries,
122
+ channel_order: channel_order,
123
123
  reliable_fetch: reliable_fetch,
124
- default_job_options: default_job_options,
124
+ default_task_options: default_task_options,
125
125
  on_complex_arguments: on_complex_arguments,
126
126
  poll_interval_average: poll_interval_average,
127
127
  shutdown_timeout: shutdown_timeout,
128
- dead_max_jobs: dead_max_jobs,
129
- dead_timeout: dead_timeout,
128
+ discarded_limit: discarded_limit,
129
+ discarded_retention: discarded_retention,
130
130
  retry_base_delay: retry_base_delay,
131
131
  retry_max_delay: retry_max_delay,
132
- client_middleware: client_middleware.snapshot,
133
- server_middleware: server_middleware.snapshot,
132
+ publish_interceptors: publish_interceptors.export,
133
+ execute_interceptors: execute_interceptors.export,
134
134
  )
135
135
  end
136
136
 
@@ -138,31 +138,31 @@ module SolidJobs
138
138
  config = new(
139
139
  redis: snapshot.fetch(:redis_config),
140
140
  concurrency: snapshot.fetch(:concurrency),
141
- queues: snapshot.fetch(:queues),
141
+ channels: snapshot.fetch(:channels),
142
142
  )
143
- config.queue_mode = snapshot.fetch(:queue_mode)
143
+ config.channel_order = snapshot.fetch(:channel_order)
144
144
  config.reliable_fetch = snapshot.fetch(:reliable_fetch)
145
- config.default_job_options = snapshot.fetch(:default_job_options)
145
+ config.default_task_options = snapshot.fetch(:default_task_options)
146
146
  config.on_complex_arguments = snapshot.fetch(:on_complex_arguments)
147
147
  config.poll_interval_average = snapshot.fetch(:poll_interval_average)
148
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)
149
+ config.discarded_limit = snapshot.fetch(:discarded_limit)
150
+ config.discarded_retention = snapshot.fetch(:discarded_retention)
151
151
  config.retry_base_delay = snapshot.fetch(:retry_base_delay)
152
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))
153
+ config.publish_interceptors.import(snapshot.fetch(:publish_interceptors))
154
+ config.execute_interceptors.import(snapshot.fetch(:execute_interceptors))
155
155
  config
156
156
  end
157
157
 
158
158
  private
159
159
 
160
- def normalize_queues(values)
160
+ def normalize_channels(values)
161
161
  Array(values).map do |value|
162
162
  name, weight = value.is_a?(Array) ? value : [value, 1]
163
163
  name = String(name)
164
164
  weight = Integer(weight)
165
- raise ArgumentError, "Queue weight must be positive" unless weight.positive?
165
+ raise ArgumentError, "Channel weight must be positive" unless weight.positive?
166
166
 
167
167
  [name.freeze, weight].freeze
168
168
  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 || Config.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.perform(*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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidJobs
6
+ class FailurePolicy < Service::Base
7
+
8
+ def call
9
+ payload = @payload.dup
10
+ now = Time.now
11
+ now_ms = Utilities.realtime_milliseconds
12
+ failure_count = Integer(payload.fetch("failure_count", 0)) + 1
13
+ payload["exception_message"] = @error.message.to_s
14
+ payload["exception_type"] = @error.class.name
15
+ payload["first_failed_ms"] ||= now_ms
16
+ payload["last_failed_ms"] = now_ms
17
+ payload["failure_count"] = failure_count
18
+ payload["channel"] = payload["retry_channel"] if payload["retry_channel"]
19
+
20
+ if retry_allowed?(payload, failure_count, now)
21
+ decision = retry_decision(payload, failure_count)
22
+ return nil if decision == :drop
23
+ return archive(payload, now.to_f) if decision == :archive
24
+
25
+ next_attempt = now.to_f + (decision || retry_delay(failure_count))
26
+ @config.redis_pool.call("ZADD", Keyspace::RETRIES, next_attempt, JSON.generate(payload))
27
+ append_message("retry scheduled")
28
+ next_attempt
29
+ else
30
+ invoke_final_failure(payload)
31
+ archive(payload, now.to_f)
32
+ append_message("task discarded")
33
+ nil
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def retry_allowed?(payload, failure_count, now)
40
+ if payload["retry_within"]
41
+ first_failure = Float(payload["first_failed_ms"]) / 1_000
42
+ now.to_f - first_failure < Float(payload["retry_within"])
43
+ else
44
+ maximum = Integer(payload.fetch("max_failures", 0))
45
+ failure_count <= maximum
46
+ end
47
+ end
48
+
49
+ def retry_delay(failure_count)
50
+ ceiling = [
51
+ Float(@config.retry_base_delay) * (2**(failure_count - 1)),
52
+ Float(@config.retry_max_delay),
53
+ ].min
54
+ (ceiling / 2) + (rand * ceiling / 2)
55
+ end
56
+
57
+ def retry_decision(payload, failure_count)
58
+ task = resolve_task(payload)
59
+ return unless task.respond_to?(:retry_delay_callback)
60
+
61
+ value = task.retry_delay_callback(failure_count, @error, payload)
62
+ return value if value.is_a?(Numeric) || %i[drop archive].include?(value)
63
+
64
+ nil
65
+ end
66
+
67
+ def invoke_final_failure(payload)
68
+ task = resolve_task(payload)
69
+ return unless task.respond_to?(:final_failure_callback)
70
+
71
+ task.final_failure_callback(payload, @error)
72
+ end
73
+
74
+ def resolve_task(payload)
75
+ Utilities.constantize(payload.fetch("task"))
76
+ rescue NameError
77
+ nil
78
+ end
79
+
80
+ def archive(payload, timestamp)
81
+ @config.redis_pool.pipelined do |pipeline|
82
+ pipeline.call("ZADD", Keyspace::DISCARDED, timestamp, JSON.generate(payload))
83
+ pipeline.call(
84
+ "ZREMRANGEBYSCORE",
85
+ Keyspace::DISCARDED,
86
+ "-inf",
87
+ timestamp - @config.discarded_retention,
88
+ )
89
+ pipeline.call(
90
+ "ZREMRANGEBYRANK",
91
+ Keyspace::DISCARDED,
92
+ 0,
93
+ -(@config.discarded_limit + 1),
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end