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,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "active_job"
|
|
4
|
+
|
|
5
|
+
if defined?(Rails::Railtie)
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
initializer "solid_jobs.active_job" do
|
|
9
|
+
ActiveSupport.on_load(:active_job) do
|
|
10
|
+
require "active_job/queue_adapters/solid_jobs_adapter"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Recovery < Service::Base
|
|
8
|
+
RESERVED_PATTERN = "*:reserved:*"
|
|
9
|
+
RESTORE_ONE = <<~LUA.freeze
|
|
10
|
+
local payload = redis.call("rpop", KEYS[1])
|
|
11
|
+
if payload then
|
|
12
|
+
local job = cjson.decode(payload)
|
|
13
|
+
redis.call("rpush", "queue:" .. job["queue"], payload)
|
|
14
|
+
return payload
|
|
15
|
+
end
|
|
16
|
+
LUA
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
recovered = 0
|
|
20
|
+
scan_keys.each do |key|
|
|
21
|
+
identity = key.split(":reserved:", 2).first
|
|
22
|
+
next if alive?(identity)
|
|
23
|
+
|
|
24
|
+
while @config.redis_pool.call("EVAL", RESTORE_ONE, 1, key)
|
|
25
|
+
recovered += 1
|
|
26
|
+
end
|
|
27
|
+
@config.redis_pool.call("DEL", key)
|
|
28
|
+
@config.redis_pool.call("DEL", "#{identity}:reservations")
|
|
29
|
+
end
|
|
30
|
+
recovered
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def alive?(identity)
|
|
36
|
+
hostname, pid = identity.split(":", 3)
|
|
37
|
+
return false if hostname == Socket.gethostname && !process_alive?(Integer(pid))
|
|
38
|
+
|
|
39
|
+
beat = @config.redis_pool.call("HGET", identity, "beat")
|
|
40
|
+
beat && Time.now.to_f - Float(beat) < Heartbeat::TTL
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def process_alive?(pid)
|
|
44
|
+
::Process.kill(0, pid)
|
|
45
|
+
true
|
|
46
|
+
rescue Errno::ESRCH
|
|
47
|
+
false
|
|
48
|
+
rescue Errno::EPERM
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def scan_keys
|
|
53
|
+
cursor = "0"
|
|
54
|
+
keys = []
|
|
55
|
+
loop do
|
|
56
|
+
cursor, found = @config.redis_pool.call(
|
|
57
|
+
"SCAN", cursor, "MATCH", RESERVED_PATTERN, "COUNT", 100,
|
|
58
|
+
)
|
|
59
|
+
keys.concat(found)
|
|
60
|
+
break if cursor == "0"
|
|
61
|
+
end
|
|
62
|
+
keys
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class RetryService < Service::Base
|
|
7
|
+
DEFAULT_MAX_RETRIES = 25
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
payload = @payload.dup
|
|
11
|
+
now = Time.now
|
|
12
|
+
now_ms = Utilities.realtime_milliseconds
|
|
13
|
+
retry_count = payload.key?("retry_count") ? Integer(payload["retry_count"]) + 1 : 0
|
|
14
|
+
payload["error_message"] = @error.message.to_s
|
|
15
|
+
payload["error_class"] = @error.class.name
|
|
16
|
+
payload["failed_at"] ||= now_ms
|
|
17
|
+
payload["retried_at"] = now_ms if retry_count.positive?
|
|
18
|
+
payload["retry_count"] = retry_count
|
|
19
|
+
payload["queue"] = payload["retry_queue"] if payload["retry_queue"]
|
|
20
|
+
|
|
21
|
+
setting = payload["retry"]
|
|
22
|
+
if setting && retry_allowed?(payload, setting, retry_count, now)
|
|
23
|
+
decision = retry_decision(payload, retry_count)
|
|
24
|
+
return nil if decision == :discard
|
|
25
|
+
return move_to_dead(payload, now.to_f) if decision == :kill
|
|
26
|
+
|
|
27
|
+
retry_at = now.to_f + (decision || retry_delay(retry_count))
|
|
28
|
+
@config.redis_pool.call("ZADD", "retry", retry_at, JSON.generate(payload))
|
|
29
|
+
append_message("retry")
|
|
30
|
+
retry_at
|
|
31
|
+
else
|
|
32
|
+
invoke_exhausted(payload)
|
|
33
|
+
move_to_dead(payload, now.to_f)
|
|
34
|
+
append_message("dead")
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def retry_allowed?(payload, setting, retry_count, now)
|
|
42
|
+
if payload["retry_for"]
|
|
43
|
+
first_failure = Float(payload["failed_at"]) / 1_000
|
|
44
|
+
now.to_f - first_failure < Float(payload["retry_for"])
|
|
45
|
+
else
|
|
46
|
+
maximum = setting == true ? DEFAULT_MAX_RETRIES : Integer(setting)
|
|
47
|
+
retry_count < maximum
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def retry_delay(retry_count)
|
|
52
|
+
ceiling = [
|
|
53
|
+
Float(@config.retry_base_delay) * (2**retry_count),
|
|
54
|
+
Float(@config.retry_max_delay),
|
|
55
|
+
].min
|
|
56
|
+
(ceiling / 2) + (rand * ceiling / 2)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def retry_decision(payload, retry_count)
|
|
60
|
+
job_class = resolve_job_class(payload)
|
|
61
|
+
return unless job_class.respond_to?(:solid_jobs_retry_in_callback)
|
|
62
|
+
|
|
63
|
+
value = job_class.solid_jobs_retry_in_callback(retry_count, @error, payload)
|
|
64
|
+
return value if value.is_a?(Numeric) || %i[discard kill].include?(value)
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def invoke_exhausted(payload)
|
|
70
|
+
job_class = resolve_job_class(payload)
|
|
71
|
+
return unless job_class.respond_to?(:solid_jobs_retries_exhausted_callback)
|
|
72
|
+
|
|
73
|
+
job_class.solid_jobs_retries_exhausted_callback(payload, @error)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_job_class(payload)
|
|
77
|
+
Utilities.constantize(payload.fetch("class"))
|
|
78
|
+
rescue NameError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def move_to_dead(payload, timestamp)
|
|
83
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
84
|
+
pipeline.call("ZADD", "dead", timestamp, JSON.generate(payload))
|
|
85
|
+
pipeline.call(
|
|
86
|
+
"ZREMRANGEBYSCORE",
|
|
87
|
+
"dead",
|
|
88
|
+
"-inf",
|
|
89
|
+
timestamp - @config.dead_timeout,
|
|
90
|
+
)
|
|
91
|
+
pipeline.call(
|
|
92
|
+
"ZREMRANGEBYRANK",
|
|
93
|
+
"dead",
|
|
94
|
+
0,
|
|
95
|
+
-(@config.dead_max_jobs + 1),
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class Scheduler
|
|
7
|
+
SETS = %w[retry schedule].freeze
|
|
8
|
+
POP_DUE = <<~LUA.freeze
|
|
9
|
+
local jobs = redis.call("zrange", KEYS[1], "-inf", ARGV[1], "byscore", "limit", 0, 1)
|
|
10
|
+
if jobs[1] then
|
|
11
|
+
redis.call("zrem", KEYS[1], jobs[1])
|
|
12
|
+
return jobs[1]
|
|
13
|
+
end
|
|
14
|
+
LUA
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@client = Client.new(config: config)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enqueue_due(now = Time.now.to_f)
|
|
22
|
+
count = 0
|
|
23
|
+
SETS.each do |set|
|
|
24
|
+
while (raw = pop_due(set, now))
|
|
25
|
+
@client.push(JSON.parse(raw))
|
|
26
|
+
count += 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
count
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run(stop:)
|
|
33
|
+
until stop.call
|
|
34
|
+
begin
|
|
35
|
+
Recovery.call(config: @config)
|
|
36
|
+
enqueue_due
|
|
37
|
+
rescue SolidRedis::ConnectionError => error
|
|
38
|
+
@config.logger.warn("Redis scheduler poll failed, retrying: #{error.class}: #{error.message}")
|
|
39
|
+
end
|
|
40
|
+
interruptible_sleep(randomized_interval, stop)
|
|
41
|
+
end
|
|
42
|
+
ensure
|
|
43
|
+
@config.close
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def pop_due(set, now)
|
|
49
|
+
@config.redis_pool.call("EVAL", POP_DUE, 1, set, now)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def randomized_interval
|
|
53
|
+
average = Float(@config.poll_interval_average)
|
|
54
|
+
average * (0.5 + rand)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def interruptible_sleep(duration, stop)
|
|
58
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + duration
|
|
59
|
+
until stop.call || ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
|
|
60
|
+
sleep 0.05
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Server
|
|
8
|
+
attr_reader :config, :identity, :ractors
|
|
9
|
+
|
|
10
|
+
def initialize(config: SolidJobs.config)
|
|
11
|
+
@config = config
|
|
12
|
+
@ractors = []
|
|
13
|
+
@scheduler = nil
|
|
14
|
+
@heartbeat = nil
|
|
15
|
+
@started = false
|
|
16
|
+
@identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
|
|
17
|
+
@started_at = Time.now.to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
raise Error, "Server is already running" if @started
|
|
22
|
+
|
|
23
|
+
snapshot = Utilities.shareable_copy(
|
|
24
|
+
config.ractor_snapshot.merge(identity: identity, started_at: @started_at),
|
|
25
|
+
)
|
|
26
|
+
Recovery.call(config: config)
|
|
27
|
+
barrier = StartupBarrier.new(config.concurrency + 2)
|
|
28
|
+
heartbeat_ready = barrier.channel
|
|
29
|
+
@heartbeat = Ractor.new(snapshot, heartbeat_ready) do |settings, ready|
|
|
30
|
+
local_config = heartbeat = nil
|
|
31
|
+
begin
|
|
32
|
+
started = SolidJobs::StartupBarrier.boot(ready) do
|
|
33
|
+
local_config = SolidJobs::Config.from_ractor_snapshot(settings)
|
|
34
|
+
SolidJobs.use_config(local_config)
|
|
35
|
+
heartbeat = SolidJobs::Heartbeat.new(
|
|
36
|
+
local_config,
|
|
37
|
+
identity: settings.fetch(:identity),
|
|
38
|
+
started_at: settings.fetch(:started_at),
|
|
39
|
+
concurrency: settings.fetch(:concurrency),
|
|
40
|
+
)
|
|
41
|
+
local_config.redis_pool.call("PING")
|
|
42
|
+
end
|
|
43
|
+
next :aborted unless started
|
|
44
|
+
messages = ::Queue.new
|
|
45
|
+
listener = Thread.new do
|
|
46
|
+
loop do
|
|
47
|
+
message = Ractor.receive
|
|
48
|
+
messages << message
|
|
49
|
+
break if message == :stop
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
quiet = false
|
|
53
|
+
stopping = false
|
|
54
|
+
work = {}
|
|
55
|
+
processed = failed = 0
|
|
56
|
+
next_beat = 0.0
|
|
57
|
+
until stopping
|
|
58
|
+
begin
|
|
59
|
+
until messages.empty?
|
|
60
|
+
case (message = messages.pop(true))
|
|
61
|
+
when :quiet
|
|
62
|
+
quiet = true
|
|
63
|
+
when :stop
|
|
64
|
+
stopping = true
|
|
65
|
+
else
|
|
66
|
+
event, *arguments = message
|
|
67
|
+
case event
|
|
68
|
+
when :work
|
|
69
|
+
work[arguments[0].to_s] = arguments[1]
|
|
70
|
+
when :done
|
|
71
|
+
work.delete(arguments[0].to_s)
|
|
72
|
+
when :stats
|
|
73
|
+
processed += arguments[0]
|
|
74
|
+
failed += arguments[1]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
rescue ThreadError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
82
|
+
if now >= next_beat
|
|
83
|
+
begin
|
|
84
|
+
heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
|
|
85
|
+
processed = failed = 0
|
|
86
|
+
rescue SolidRedis::ConnectionError => error
|
|
87
|
+
local_config.logger.warn("Redis heartbeat failed, retrying: #{error.class}: #{error.message}")
|
|
88
|
+
end
|
|
89
|
+
next_beat = now + SolidJobs::Heartbeat::INTERVAL
|
|
90
|
+
end
|
|
91
|
+
sleep 0.05 unless stopping
|
|
92
|
+
end
|
|
93
|
+
begin
|
|
94
|
+
heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
|
|
95
|
+
heartbeat.cleanup
|
|
96
|
+
rescue SolidRedis::ConnectionError => error
|
|
97
|
+
local_config.logger.warn("Redis heartbeat cleanup failed: #{error.class}: #{error.message}")
|
|
98
|
+
end
|
|
99
|
+
listener.join
|
|
100
|
+
:stopped
|
|
101
|
+
ensure
|
|
102
|
+
local_config&.close
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
barrier.register!(@heartbeat, heartbeat_ready)
|
|
106
|
+
@ractors = Array.new(config.concurrency) do |processor_id|
|
|
107
|
+
ready = barrier.channel
|
|
108
|
+
ractor = Ractor.new(
|
|
109
|
+
snapshot, processor_id, @heartbeat, ready,
|
|
110
|
+
) do |settings, id, heartbeat_port, ready_port|
|
|
111
|
+
local_config = processor = nil
|
|
112
|
+
begin
|
|
113
|
+
started = SolidJobs::StartupBarrier.boot(ready_port) do
|
|
114
|
+
local_config = SolidJobs::Config.from_ractor_snapshot(settings)
|
|
115
|
+
SolidJobs.use_config(local_config)
|
|
116
|
+
processor = SolidJobs::Processor.new(
|
|
117
|
+
local_config,
|
|
118
|
+
identity: settings.fetch(:identity),
|
|
119
|
+
processor_id: id,
|
|
120
|
+
heartbeat: heartbeat_port,
|
|
121
|
+
)
|
|
122
|
+
local_config.redis_pool.call("PING")
|
|
123
|
+
end
|
|
124
|
+
next :aborted unless started
|
|
125
|
+
control = ::Queue.new
|
|
126
|
+
listener = Thread.new do
|
|
127
|
+
loop do
|
|
128
|
+
command = Ractor.receive
|
|
129
|
+
control << command
|
|
130
|
+
next unless command == :stop
|
|
131
|
+
|
|
132
|
+
if processor.busy?
|
|
133
|
+
sleep settings.fetch(:shutdown_timeout)
|
|
134
|
+
processor.interrupt_current
|
|
135
|
+
end
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
result = processor.run(control: control)
|
|
140
|
+
listener.join
|
|
141
|
+
result
|
|
142
|
+
ensure
|
|
143
|
+
local_config&.close
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
barrier.register!(ractor, ready)
|
|
147
|
+
ractor
|
|
148
|
+
end
|
|
149
|
+
scheduler_ready = barrier.channel
|
|
150
|
+
@scheduler = Ractor.new(
|
|
151
|
+
snapshot, scheduler_ready,
|
|
152
|
+
) do |settings, ready|
|
|
153
|
+
local_config = scheduler = nil
|
|
154
|
+
begin
|
|
155
|
+
started = SolidJobs::StartupBarrier.boot(ready) do
|
|
156
|
+
local_config = SolidJobs::Config.from_ractor_snapshot(settings)
|
|
157
|
+
SolidJobs.use_config(local_config)
|
|
158
|
+
scheduler = SolidJobs::Scheduler.new(local_config)
|
|
159
|
+
local_config.redis_pool.call("PING")
|
|
160
|
+
end
|
|
161
|
+
next :aborted unless started
|
|
162
|
+
control = ::Queue.new
|
|
163
|
+
listener = Thread.new do
|
|
164
|
+
command = Ractor.receive
|
|
165
|
+
control << command
|
|
166
|
+
end
|
|
167
|
+
scheduler.run(stop: -> { !control.empty? })
|
|
168
|
+
listener.join
|
|
169
|
+
:stopped
|
|
170
|
+
ensure
|
|
171
|
+
local_config&.close
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
barrier.register!(@scheduler, scheduler_ready)
|
|
175
|
+
barrier.run!
|
|
176
|
+
@started = true
|
|
177
|
+
config.fire(:startup, self)
|
|
178
|
+
self
|
|
179
|
+
rescue StandardError
|
|
180
|
+
barrier&.abort!
|
|
181
|
+
@ractors = []
|
|
182
|
+
@scheduler = @heartbeat = nil
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def quiet
|
|
187
|
+
return self unless @started
|
|
188
|
+
|
|
189
|
+
@ractors.each { |ractor| ractor.send(:quiet) }
|
|
190
|
+
@heartbeat&.send(:quiet)
|
|
191
|
+
config.fire(:quiet, self)
|
|
192
|
+
self
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def stop
|
|
196
|
+
return [] unless @started
|
|
197
|
+
|
|
198
|
+
@ractors.each { |ractor| ractor.send(:stop) }
|
|
199
|
+
@scheduler&.send(:stop)
|
|
200
|
+
results = @ractors.map { |ractor| RactorSupport.value(ractor) }
|
|
201
|
+
RactorSupport.value(@scheduler) if @scheduler
|
|
202
|
+
@heartbeat&.send(:stop)
|
|
203
|
+
RactorSupport.value(@heartbeat) if @heartbeat
|
|
204
|
+
@ractors = []
|
|
205
|
+
@scheduler = nil
|
|
206
|
+
@heartbeat = nil
|
|
207
|
+
@started = false
|
|
208
|
+
config.fire(:shutdown, self)
|
|
209
|
+
results
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def running?
|
|
213
|
+
@started
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def remote_signal
|
|
217
|
+
config.redis_pool.call("RPOP", "#{identity}-signals")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class StartupBarrier
|
|
7
|
+
STATES = %i[booting all_ready running boot_failed].freeze
|
|
8
|
+
Component = Struct.new(:ractor, :start_channel, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :expected_count, :ready_count, :state
|
|
11
|
+
|
|
12
|
+
def initialize(expected_count, timeout: 10)
|
|
13
|
+
@expected_count = Integer(expected_count)
|
|
14
|
+
raise ArgumentError, "expected_count must be positive" unless @expected_count.positive?
|
|
15
|
+
|
|
16
|
+
@timeout = Float(timeout)
|
|
17
|
+
raise ArgumentError, "timeout must be positive" unless @timeout.positive?
|
|
18
|
+
|
|
19
|
+
@components = []
|
|
20
|
+
@registered = {}
|
|
21
|
+
@ready_count = 0
|
|
22
|
+
@state = :booting
|
|
23
|
+
@cleaned = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def channel
|
|
27
|
+
ensure_state!(:booting)
|
|
28
|
+
Ractor::Port.new if defined?(Ractor::Port)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register!(ractor, channel)
|
|
32
|
+
ensure_state!(:booting)
|
|
33
|
+
identity = ractor.object_id
|
|
34
|
+
raise Error, "Ractor announced READY more than once" if @registered.key?(identity)
|
|
35
|
+
|
|
36
|
+
signal, start_channel = wait_for_signal(ractor, channel)
|
|
37
|
+
unless signal == :ready
|
|
38
|
+
RactorSupport.value(ractor)
|
|
39
|
+
raise Error, "Ractor failed during boot"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@registered[identity] = true
|
|
43
|
+
@components << Component.new(ractor: ractor, start_channel: start_channel)
|
|
44
|
+
@ready_count += 1
|
|
45
|
+
@state = :all_ready if @ready_count == expected_count
|
|
46
|
+
ractor
|
|
47
|
+
rescue Timeout::Error
|
|
48
|
+
boot_failed!(Error.new("Ractor boot timed out after #{@timeout}s"))
|
|
49
|
+
rescue StandardError => error
|
|
50
|
+
boot_failed!(error)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run!
|
|
54
|
+
ensure_state!(:all_ready)
|
|
55
|
+
@components.each { |component| signal(component, :start) }
|
|
56
|
+
@state = :running
|
|
57
|
+
@components.map(&:ractor)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def abort!
|
|
61
|
+
return self if @cleaned || state == :running
|
|
62
|
+
|
|
63
|
+
@state = :boot_failed
|
|
64
|
+
@cleaned = true
|
|
65
|
+
@components.each { |component| signal(component, :abort) }
|
|
66
|
+
@components.each do |component|
|
|
67
|
+
RactorSupport.value(component.ractor)
|
|
68
|
+
rescue Ractor::RemoteError, Ractor::ClosedError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.boot(channel)
|
|
75
|
+
announced = false
|
|
76
|
+
yield
|
|
77
|
+
command = if channel
|
|
78
|
+
start_channel = Ractor::Port.new
|
|
79
|
+
channel << [:ready, start_channel]
|
|
80
|
+
announced = true
|
|
81
|
+
start_channel.receive
|
|
82
|
+
else
|
|
83
|
+
Ractor.yield(:ready)
|
|
84
|
+
announced = true
|
|
85
|
+
Ractor.receive
|
|
86
|
+
end
|
|
87
|
+
return false if command == :abort
|
|
88
|
+
raise Error, "Invalid startup command: #{command.inspect}" unless command == :start
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
rescue StandardError
|
|
92
|
+
channel << [:boot_error, nil] if channel && !announced
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def boot_failed!(error)
|
|
99
|
+
@state = :boot_failed
|
|
100
|
+
abort!
|
|
101
|
+
raise error
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def wait_for_signal(ractor, channel)
|
|
105
|
+
Timeout.timeout(@timeout) do
|
|
106
|
+
channel ? channel.receive : [ractor.take, nil]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def signal(component, command)
|
|
111
|
+
if component.start_channel
|
|
112
|
+
component.start_channel << command
|
|
113
|
+
else
|
|
114
|
+
component.ractor.send(command)
|
|
115
|
+
end
|
|
116
|
+
rescue Ractor::ClosedError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_state!(required)
|
|
121
|
+
return if state == required
|
|
122
|
+
|
|
123
|
+
raise Error, "StartupBarrier is #{state}, expected #{required}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
module Testing
|
|
5
|
+
MODES = %i[disable fake inline].freeze
|
|
6
|
+
STORAGE_KEY = :solid_jobs_testing_jobs
|
|
7
|
+
MODE_KEY = :solid_jobs_testing_mode
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def mode
|
|
12
|
+
Thread.current[MODE_KEY] || :disable
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def testing!(new_mode)
|
|
16
|
+
new_mode = new_mode.to_sym
|
|
17
|
+
raise ArgumentError, "Unknown testing mode: #{new_mode.inspect}" unless MODES.include?(new_mode)
|
|
18
|
+
|
|
19
|
+
if block_given?
|
|
20
|
+
previous = mode
|
|
21
|
+
Thread.current[MODE_KEY] = new_mode
|
|
22
|
+
begin
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
Thread.current[MODE_KEY] = previous
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
Thread.current[MODE_KEY] = new_mode
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fake!
|
|
33
|
+
testing!(:fake) { yield } if block_given?
|
|
34
|
+
testing!(:fake) unless block_given?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inline!
|
|
38
|
+
testing!(:inline) { yield } if block_given?
|
|
39
|
+
testing!(:inline) unless block_given?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disable!
|
|
43
|
+
testing!(:disable) { yield } if block_given?
|
|
44
|
+
testing!(:disable) unless block_given?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def jobs_for(job_class)
|
|
48
|
+
storage[job_class.name] ||= []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clear_all
|
|
52
|
+
storage.clear
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def execute_inline(payload)
|
|
56
|
+
job_class = payload["class"]
|
|
57
|
+
job_class = Utilities.constantize(job_class) if job_class.is_a?(String)
|
|
58
|
+
instance = job_class.new
|
|
59
|
+
instance.jid = payload["jid"] if instance.respond_to?(:jid=)
|
|
60
|
+
SolidJobs.config.server_middleware.invoke(instance, payload, payload.fetch("queue", "default")) do
|
|
61
|
+
instance.perform(*payload.fetch("args"))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def storage
|
|
66
|
+
Thread.current[STORAGE_KEY] ||= {}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|