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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +22 -0
- data/README.md +159 -0
- data/docs/reliability.md +114 -0
- data/exe/solid-jobs +8 -0
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +42 -0
- data/lib/solid_jobs/active_job.rb +16 -0
- data/lib/solid_jobs/api.rb +457 -0
- data/lib/solid_jobs/cli.rb +135 -0
- data/lib/solid_jobs/client.rb +202 -0
- data/lib/solid_jobs/config.rb +171 -0
- data/lib/solid_jobs/errors.rb +9 -0
- data/lib/solid_jobs/fetch.rb +310 -0
- data/lib/solid_jobs/heartbeat.rb +60 -0
- data/lib/solid_jobs/integrity_check.rb +222 -0
- data/lib/solid_jobs/job.rb +156 -0
- data/lib/solid_jobs/middleware/chain.rb +104 -0
- data/lib/solid_jobs/processor.rb +152 -0
- data/lib/solid_jobs/ractor_support.rb +11 -0
- data/lib/solid_jobs/rails.rb +16 -0
- data/lib/solid_jobs/recovery.rb +65 -0
- data/lib/solid_jobs/retry_service.rb +100 -0
- data/lib/solid_jobs/scheduler.rb +64 -0
- data/lib/solid_jobs/server.rb +221 -0
- data/lib/solid_jobs/startup_barrier.rb +126 -0
- data/lib/solid_jobs/testing.rb +69 -0
- data/lib/solid_jobs/utilities.rb +52 -0
- data/lib/solid_jobs/version.rb +6 -0
- data/lib/solid_jobs/worker.rb +35 -0
- data/lib/solid_jobs.rb +92 -0
- metadata +189 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Heartbeat
|
|
8
|
+
INTERVAL = 5
|
|
9
|
+
TTL = 60
|
|
10
|
+
|
|
11
|
+
def initialize(config, identity:, started_at:, concurrency:)
|
|
12
|
+
@config = config
|
|
13
|
+
@identity = identity
|
|
14
|
+
@started_at = started_at
|
|
15
|
+
@concurrency = concurrency
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def beat(quiet: false, work: {}, processed: 0, failed: 0)
|
|
19
|
+
now = Time.now.to_f
|
|
20
|
+
info = {
|
|
21
|
+
"hostname" => Socket.gethostname,
|
|
22
|
+
"pid" => ::Process.pid,
|
|
23
|
+
"started_at" => @started_at,
|
|
24
|
+
"queues" => @config.queues,
|
|
25
|
+
"labels" => [],
|
|
26
|
+
"identity" => @identity,
|
|
27
|
+
}
|
|
28
|
+
busy = @config.redis_pool.call("HLEN", "#{@identity}:work")
|
|
29
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
30
|
+
pipeline.call("INCRBY", "stat:processed", processed) if processed.positive?
|
|
31
|
+
pipeline.call("INCRBY", "stat:failed", failed) if failed.positive?
|
|
32
|
+
pipeline.call("SADD", "processes", @identity)
|
|
33
|
+
pipeline.call(
|
|
34
|
+
"HSET",
|
|
35
|
+
@identity,
|
|
36
|
+
"info", JSON.generate(info),
|
|
37
|
+
"beat", now,
|
|
38
|
+
"busy", busy,
|
|
39
|
+
"quiet", quiet ? "true" : "false",
|
|
40
|
+
"concurrency", @concurrency,
|
|
41
|
+
)
|
|
42
|
+
pipeline.call("EXPIRE", @identity, TTL)
|
|
43
|
+
pipeline.call("DEL", "#{@identity}:work")
|
|
44
|
+
pipeline.call("HSET", "#{@identity}:work", *work.flatten(1)) unless work.empty?
|
|
45
|
+
pipeline.call("EXPIRE", "#{@identity}:work", TTL)
|
|
46
|
+
end
|
|
47
|
+
now
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cleanup
|
|
51
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
52
|
+
pipeline.call("SREM", "processes", @identity)
|
|
53
|
+
pipeline.call("DEL", @identity)
|
|
54
|
+
pipeline.call("DEL", "#{@identity}:work")
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
@config.close
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class IntegrityCheck
|
|
8
|
+
ACTIVE_SETS = {
|
|
9
|
+
"schedule" => "SCHEDULED",
|
|
10
|
+
"retry" => "RETRY",
|
|
11
|
+
"dead" => "DEAD",
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
Report = Struct.new(
|
|
15
|
+
:lost,
|
|
16
|
+
:orphaned,
|
|
17
|
+
:invalid_reservations,
|
|
18
|
+
:dangling_indexes,
|
|
19
|
+
:duplicates,
|
|
20
|
+
:malformed_payloads,
|
|
21
|
+
:states,
|
|
22
|
+
keyword_init: true,
|
|
23
|
+
) do
|
|
24
|
+
def ok?
|
|
25
|
+
lost.empty? &&
|
|
26
|
+
orphaned.empty? &&
|
|
27
|
+
invalid_reservations.empty? &&
|
|
28
|
+
dangling_indexes.empty? &&
|
|
29
|
+
duplicates.empty? &&
|
|
30
|
+
malformed_payloads.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.call(expected_job_ids:, acked_job_ids: [], acked_key: nil, config: SolidJobs.config)
|
|
35
|
+
new(
|
|
36
|
+
expected_job_ids: expected_job_ids,
|
|
37
|
+
acked_job_ids: acked_job_ids,
|
|
38
|
+
acked_key: acked_key,
|
|
39
|
+
config: config,
|
|
40
|
+
).call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(expected_job_ids:, acked_job_ids:, acked_key:, config:)
|
|
44
|
+
@expected = expected_job_ids.map(&:to_s).to_set
|
|
45
|
+
@acked = acked_job_ids.map(&:to_s).to_set
|
|
46
|
+
@acked.merge(Array(config.redis_pool.call("SMEMBERS", acked_key))) if acked_key
|
|
47
|
+
@redis = config.redis_pool
|
|
48
|
+
@states = Hash.new { |hash, job_id| hash[job_id] = [] }
|
|
49
|
+
@malformed_payloads = []
|
|
50
|
+
@invalid_reservations = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call
|
|
54
|
+
read_queues
|
|
55
|
+
read_sorted_sets
|
|
56
|
+
reserved = read_reservations
|
|
57
|
+
validate_reservation_metadata(reserved)
|
|
58
|
+
@acked.each { |job_id| add_state(job_id, "ACKED", "acknowledged") }
|
|
59
|
+
|
|
60
|
+
observed = @states.keys.to_set
|
|
61
|
+
Report.new(
|
|
62
|
+
lost: (@expected - observed).sort,
|
|
63
|
+
orphaned: (observed - @expected).sort,
|
|
64
|
+
invalid_reservations: @invalid_reservations,
|
|
65
|
+
dangling_indexes: dangling_attempts,
|
|
66
|
+
duplicates: duplicates,
|
|
67
|
+
malformed_payloads: @malformed_payloads,
|
|
68
|
+
states: @states.to_h,
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def read_queues
|
|
75
|
+
scan("queue:*").each do |key|
|
|
76
|
+
Array(@redis.call("LRANGE", key, 0, -1)).each_with_index do |raw, index|
|
|
77
|
+
add_payload(raw, "READY", "#{key}[#{index}]")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_sorted_sets
|
|
83
|
+
ACTIVE_SETS.each do |key, state|
|
|
84
|
+
Array(@redis.call("ZRANGE", key, 0, -1)).each_with_index do |raw, index|
|
|
85
|
+
add_payload(raw, state, "#{key}[#{index}]")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def read_reservations
|
|
91
|
+
scan("*:reserved:*").to_h do |key|
|
|
92
|
+
records = Array(@redis.call("LRANGE", key, 0, -1)).each_with_index.map do |raw, index|
|
|
93
|
+
payload = parse_payload(raw, "#{key}[#{index}]")
|
|
94
|
+
add_state(payload["jid"], "RESERVED", "#{key}[#{index}]") if payload
|
|
95
|
+
payload
|
|
96
|
+
end.compact
|
|
97
|
+
[key, records]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_reservation_metadata(reserved)
|
|
102
|
+
metadata = reservation_metadata
|
|
103
|
+
reserved.each do |key, payloads|
|
|
104
|
+
identity, worker_id = key.split(":reserved:", 2)
|
|
105
|
+
entry = metadata.delete([identity, worker_id])
|
|
106
|
+
if payloads.empty?
|
|
107
|
+
@invalid_reservations << issue(key, "empty_reserved_list")
|
|
108
|
+
elsif payloads.length > 1
|
|
109
|
+
@invalid_reservations << issue(key, "multiple_payloads_for_worker")
|
|
110
|
+
end
|
|
111
|
+
payload = payloads.first
|
|
112
|
+
unless entry
|
|
113
|
+
@invalid_reservations << issue(key, "missing_metadata", job_id: payload&.[]("jid"))
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
next unless payload
|
|
117
|
+
|
|
118
|
+
attributes = entry.fetch(:attributes)
|
|
119
|
+
{
|
|
120
|
+
"job_id" => payload["jid"],
|
|
121
|
+
"process_id" => identity,
|
|
122
|
+
"worker_id" => worker_id,
|
|
123
|
+
"queue" => payload["queue"],
|
|
124
|
+
}.each do |field, expected|
|
|
125
|
+
next if attributes[field].to_s == expected.to_s
|
|
126
|
+
|
|
127
|
+
@invalid_reservations << issue(
|
|
128
|
+
key,
|
|
129
|
+
"metadata_mismatch",
|
|
130
|
+
field: field,
|
|
131
|
+
expected: expected,
|
|
132
|
+
actual: attributes[field],
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
metadata.each_value do |entry|
|
|
137
|
+
@invalid_reservations << issue(
|
|
138
|
+
entry.fetch(:key),
|
|
139
|
+
"metadata_without_payload",
|
|
140
|
+
field: entry.fetch(:field),
|
|
141
|
+
job_id: entry.fetch(:attributes)["job_id"],
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def reservation_metadata
|
|
147
|
+
scan("*:reservations").each_with_object({}) do |key, entries|
|
|
148
|
+
identity = key.delete_suffix(":reservations")
|
|
149
|
+
hash_entries(key).each do |field, raw|
|
|
150
|
+
attributes = parse_json(raw, "#{key}[#{field}]")
|
|
151
|
+
next unless attributes
|
|
152
|
+
|
|
153
|
+
entries[[identity, field]] = {
|
|
154
|
+
key: key,
|
|
155
|
+
field: field,
|
|
156
|
+
attributes: attributes,
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def dangling_attempts
|
|
163
|
+
hash_entries("solid-jobs:attempts").filter_map do |job_id, attempt|
|
|
164
|
+
next if @states[job_id]&.any? { |location| location.fetch(:state) != "ACKED" }
|
|
165
|
+
|
|
166
|
+
{job_id: job_id, attempt: Integer(attempt), index: "solid-jobs:attempts"}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def duplicates
|
|
171
|
+
@states.filter_map do |job_id, locations|
|
|
172
|
+
next unless locations.length > 1
|
|
173
|
+
|
|
174
|
+
{job_id: job_id, locations: locations}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def add_payload(raw, state, location)
|
|
179
|
+
payload = parse_payload(raw, location)
|
|
180
|
+
add_state(payload["jid"], state, location) if payload
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_payload(raw, location)
|
|
184
|
+
payload = parse_json(raw, location)
|
|
185
|
+
return unless payload
|
|
186
|
+
return payload if payload["jid"]
|
|
187
|
+
|
|
188
|
+
@malformed_payloads << {location: location, error: "missing jid", raw: raw}
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_json(raw, location)
|
|
193
|
+
JSON.parse(raw)
|
|
194
|
+
rescue JSON::ParserError => error
|
|
195
|
+
@malformed_payloads << {location: location, error: error.message, raw: raw}
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def add_state(job_id, state, location)
|
|
200
|
+
@states[job_id.to_s] << {state: state, location: location}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def hash_entries(key)
|
|
204
|
+
Array(@redis.call("HGETALL", key)).each_slice(2).to_h
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def scan(pattern)
|
|
208
|
+
cursor = "0"
|
|
209
|
+
keys = []
|
|
210
|
+
loop do
|
|
211
|
+
cursor, batch = @redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100)
|
|
212
|
+
keys.concat(batch)
|
|
213
|
+
break if cursor == "0"
|
|
214
|
+
end
|
|
215
|
+
keys
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def issue(key, reason, **details)
|
|
219
|
+
{key: key, reason: reason}.merge(details)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
module Job
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
base.include(InstanceMethods)
|
|
8
|
+
base.solid_jobs_options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module InstanceMethods
|
|
12
|
+
attr_accessor :jid
|
|
13
|
+
attr_writer :_context
|
|
14
|
+
|
|
15
|
+
def _context
|
|
16
|
+
@_context ||= {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def logger
|
|
20
|
+
SolidJobs.config.logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def interrupted?
|
|
24
|
+
_context[:interrupted]&.call || false
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def solid_jobs_options(options = nil)
|
|
30
|
+
if options
|
|
31
|
+
merged = get_solid_jobs_options.merge(Utilities.stringify_keys(options))
|
|
32
|
+
@solid_jobs_options = Utilities.shareable_copy(merged)
|
|
33
|
+
else
|
|
34
|
+
@solid_jobs_options ||= Utilities.shareable_copy(SolidJobs.config.default_job_options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
alias_method :sidekiq_options, :solid_jobs_options
|
|
38
|
+
|
|
39
|
+
def get_solid_jobs_options
|
|
40
|
+
solid_jobs_options
|
|
41
|
+
end
|
|
42
|
+
alias_method :get_sidekiq_options, :get_solid_jobs_options
|
|
43
|
+
|
|
44
|
+
def solid_jobs_retry_in(&block)
|
|
45
|
+
define_singleton_method(:solid_jobs_retry_in_callback, &block) if block
|
|
46
|
+
method(:solid_jobs_retry_in_callback) if respond_to?(:solid_jobs_retry_in_callback)
|
|
47
|
+
end
|
|
48
|
+
alias_method :sidekiq_retry_in, :solid_jobs_retry_in
|
|
49
|
+
|
|
50
|
+
def solid_jobs_retries_exhausted(&block)
|
|
51
|
+
define_singleton_method(:solid_jobs_retries_exhausted_callback, &block) if block
|
|
52
|
+
method(:solid_jobs_retries_exhausted_callback) if respond_to?(:solid_jobs_retries_exhausted_callback)
|
|
53
|
+
end
|
|
54
|
+
alias_method :sidekiq_retries_exhausted, :solid_jobs_retries_exhausted
|
|
55
|
+
|
|
56
|
+
def perform_async(*args)
|
|
57
|
+
client_push("class" => self, "args" => args)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def perform_bulk(args, batch_size: Client::DEFAULT_BATCH_SIZE)
|
|
61
|
+
Client.new.push_bulk(
|
|
62
|
+
get_solid_jobs_options.merge(
|
|
63
|
+
"class" => self,
|
|
64
|
+
"args" => args,
|
|
65
|
+
"batch_size" => batch_size,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def perform_in(interval, *args)
|
|
71
|
+
timestamp = interval.is_a?(Time) ? interval.to_f : Float(interval)
|
|
72
|
+
timestamp += Time.now.to_f if timestamp < 1_000_000_000
|
|
73
|
+
item = {"class" => self, "args" => args}
|
|
74
|
+
item["at"] = timestamp if timestamp > Time.now.to_f
|
|
75
|
+
client_push(item)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def perform_at(timestamp, *args)
|
|
79
|
+
perform_in(timestamp, *args)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def perform_inline(*args)
|
|
83
|
+
payload = get_solid_jobs_options.merge(
|
|
84
|
+
"class" => self,
|
|
85
|
+
"args" => args,
|
|
86
|
+
)
|
|
87
|
+
SolidJobs::Testing.execute_inline(payload)
|
|
88
|
+
end
|
|
89
|
+
alias_method :perform_sync, :perform_inline
|
|
90
|
+
|
|
91
|
+
def set(options)
|
|
92
|
+
Setter.new(self, options)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def client_push(item)
|
|
96
|
+
Client.new.push(get_solid_jobs_options.merge(item))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def jobs
|
|
100
|
+
Testing.jobs_for(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def clear
|
|
104
|
+
jobs.clear
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def perform_one
|
|
108
|
+
payload = jobs.shift
|
|
109
|
+
raise EmptyQueueError, "No jobs for #{name}" unless payload
|
|
110
|
+
|
|
111
|
+
Testing.execute_inline(payload)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class Setter
|
|
116
|
+
def initialize(job_class, options)
|
|
117
|
+
@job_class = job_class
|
|
118
|
+
@options = Utilities.stringify_keys(options)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def perform_async(*args)
|
|
122
|
+
if @options.delete("sync")
|
|
123
|
+
@job_class.perform_inline(*args)
|
|
124
|
+
elsif (wait = @options.delete("wait"))
|
|
125
|
+
perform_in(wait, *args)
|
|
126
|
+
elsif (wait_until = @options.delete("wait_until"))
|
|
127
|
+
perform_at(wait_until, *args)
|
|
128
|
+
else
|
|
129
|
+
push(args)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def perform_in(interval, *args)
|
|
134
|
+
timestamp = interval.is_a?(Time) ? interval.to_f : Float(interval)
|
|
135
|
+
timestamp += Time.now.to_f if timestamp < 1_000_000_000
|
|
136
|
+
push(args, "at" => timestamp)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def perform_at(timestamp, *args)
|
|
140
|
+
perform_in(timestamp, *args)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def push(args, extra = {})
|
|
146
|
+
Client.new.push(@job_class.get_solid_jobs_options.merge(@options).merge(
|
|
147
|
+
extra,
|
|
148
|
+
"class" => @job_class,
|
|
149
|
+
"args" => args,
|
|
150
|
+
))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
class EmptyQueueError < Error; end
|
|
156
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
module Middleware
|
|
5
|
+
class Chain
|
|
6
|
+
Entry = Struct.new(:klass, :args, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(copy = nil)
|
|
11
|
+
@entries = copy ? copy.map { |entry| Entry.new(klass: entry.klass, args: entry.args.dup) } : []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def each(&block)
|
|
15
|
+
@entries.each(&block)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(klass, *args)
|
|
19
|
+
remove(klass)
|
|
20
|
+
@entries << Entry.new(klass: klass, args: args)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def prepend(klass, *args)
|
|
25
|
+
remove(klass)
|
|
26
|
+
@entries.unshift(Entry.new(klass: klass, args: args))
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remove(klass)
|
|
31
|
+
@entries.delete_if { |entry| entry.klass == klass }
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def insert_before(oldklass, newklass, *args)
|
|
36
|
+
insert_relative(oldklass, newklass, args, 0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def insert_after(oldklass, newklass, *args)
|
|
40
|
+
insert_relative(oldklass, newklass, args, 1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exists?(klass)
|
|
44
|
+
@entries.any? { |entry| entry.klass == klass }
|
|
45
|
+
end
|
|
46
|
+
alias_method :include?, :exists?
|
|
47
|
+
|
|
48
|
+
def clear
|
|
49
|
+
@entries.clear
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty?
|
|
54
|
+
@entries.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def retrieve
|
|
58
|
+
@entries.map { |entry| entry.klass.new(*entry.args) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def snapshot
|
|
62
|
+
Utilities.shareable_copy(
|
|
63
|
+
@entries.map { |entry| [entry.klass, entry.args] },
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def restore(snapshot)
|
|
68
|
+
clear
|
|
69
|
+
snapshot.each { |klass, args| add(klass, *args) }
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def invoke(*arguments, &final)
|
|
74
|
+
return yield if empty?
|
|
75
|
+
|
|
76
|
+
stack = retrieve
|
|
77
|
+
traverse(stack, 0, arguments, &final)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dup
|
|
81
|
+
self.class.new(@entries)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def traverse(stack, index, arguments, &final)
|
|
87
|
+
return final.call if index >= stack.length
|
|
88
|
+
|
|
89
|
+
stack[index].call(*arguments) do
|
|
90
|
+
traverse(stack, index + 1, arguments, &final)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def insert_relative(oldklass, newklass, args, offset)
|
|
95
|
+
remove(newklass)
|
|
96
|
+
index = @entries.index { |entry| entry.klass == oldklass }
|
|
97
|
+
raise ArgumentError, "#{oldklass} is not in the middleware chain" unless index
|
|
98
|
+
|
|
99
|
+
@entries.insert(index + offset, Entry.new(klass: newklass, args: args))
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class Processor
|
|
7
|
+
def initialize(config, identity: nil, processor_id: nil, heartbeat: nil)
|
|
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)
|
|
11
|
+
@identity = identity
|
|
12
|
+
@processor_id = processor_id
|
|
13
|
+
@heartbeat = heartbeat
|
|
14
|
+
@state_mutex = Mutex.new
|
|
15
|
+
@busy = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(control:)
|
|
19
|
+
@state_mutex.synchronize { @runner = Thread.current }
|
|
20
|
+
processed = 0
|
|
21
|
+
failed = 0
|
|
22
|
+
@quiet = false
|
|
23
|
+
@stopping = false
|
|
24
|
+
loop do
|
|
25
|
+
receive_commands(control)
|
|
26
|
+
break if @stopping
|
|
27
|
+
if @quiet
|
|
28
|
+
sleep 0.05
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
unit = retrieve
|
|
33
|
+
next unless unit
|
|
34
|
+
|
|
35
|
+
receive_commands(control)
|
|
36
|
+
if @stopping || @quiet
|
|
37
|
+
requeue(unit)
|
|
38
|
+
break if @stopping
|
|
39
|
+
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
@state_mutex.synchronize { @busy = true }
|
|
45
|
+
payload = unit.job
|
|
46
|
+
register_work(unit, payload)
|
|
47
|
+
begin
|
|
48
|
+
@worker.perform(payload)
|
|
49
|
+
rescue Shutdown
|
|
50
|
+
requeue(unit)
|
|
51
|
+
break
|
|
52
|
+
rescue StandardError => error
|
|
53
|
+
RetryService.call(config: @config, payload: payload, error: error)
|
|
54
|
+
acknowledge(unit)
|
|
55
|
+
failed += 1
|
|
56
|
+
else
|
|
57
|
+
processed += 1 if acknowledge(unit)
|
|
58
|
+
end
|
|
59
|
+
ensure
|
|
60
|
+
@state_mutex.synchronize { @busy = false }
|
|
61
|
+
clear_work
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
{processed: processed, failed: failed}
|
|
65
|
+
ensure
|
|
66
|
+
send_heartbeat(:stats, processed || 0, failed || 0)
|
|
67
|
+
@config.close
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def busy?
|
|
71
|
+
@state_mutex.synchronize { @busy }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def interrupt_current
|
|
75
|
+
runner = @state_mutex.synchronize { @runner if @busy }
|
|
76
|
+
runner&.raise(Shutdown, "SolidJobs shutdown timeout exceeded")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def acknowledge(unit)
|
|
82
|
+
acknowledged = unit.acknowledge
|
|
83
|
+
unless acknowledged
|
|
84
|
+
@config.logger.warn(
|
|
85
|
+
"Reservation fencing rejected stale ACK: #{unit.reservation_id}",
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
acknowledged
|
|
89
|
+
rescue SolidRedis::ConnectionError => error
|
|
90
|
+
@fetcher.connection_failed!
|
|
91
|
+
@config.logger.warn(
|
|
92
|
+
"Redis ACK failed; reserved job will be replayed: #{error.class}: #{error.message}",
|
|
93
|
+
)
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def requeue(unit)
|
|
98
|
+
requeued = unit.requeue
|
|
99
|
+
unless requeued
|
|
100
|
+
@config.logger.warn(
|
|
101
|
+
"Reservation fencing rejected stale requeue: #{unit.reservation_id}",
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
requeued
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def retrieve
|
|
108
|
+
@fetcher.retrieve
|
|
109
|
+
rescue SolidRedis::ConnectionError => error
|
|
110
|
+
@fetcher.connection_failed!
|
|
111
|
+
@config.logger.warn("Redis fetch failed, retrying: #{error.class}: #{error.message}")
|
|
112
|
+
sleep 0.1
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def register_work(unit, payload)
|
|
117
|
+
return unless @identity
|
|
118
|
+
|
|
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,
|
|
125
|
+
)
|
|
126
|
+
send_heartbeat(:work, @processor_id, work)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def clear_work
|
|
130
|
+
return unless @identity
|
|
131
|
+
|
|
132
|
+
send_heartbeat(:done, @processor_id)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def send_heartbeat(*message)
|
|
136
|
+
@heartbeat&.send(message, move: true)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def receive_commands(control)
|
|
140
|
+
until control.empty?
|
|
141
|
+
case control.pop(true)
|
|
142
|
+
when :quiet
|
|
143
|
+
@quiet = true
|
|
144
|
+
when :stop
|
|
145
|
+
@stopping = true
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
rescue ThreadError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|