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,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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ module RactorSupport
5
+ module_function
6
+
7
+ def value(ractor)
8
+ ractor.respond_to?(:value) ? ractor.value : ractor.take
9
+ end
10
+ end
11
+ end