solid-jobs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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