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,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidJobs
6
+ class Stats
7
+ attr_reader :config
8
+
9
+ def initialize(config: SolidJobs.config)
10
+ @config = config
11
+ end
12
+
13
+ def processed
14
+ integer("stat:processed")
15
+ end
16
+
17
+ def failed
18
+ integer("stat:failed")
19
+ end
20
+
21
+ def enqueued
22
+ queues.sum(&:size)
23
+ end
24
+
25
+ def scheduled_size
26
+ integer_command("ZCARD", "schedule")
27
+ end
28
+
29
+ def retry_size
30
+ integer_command("ZCARD", "retry")
31
+ end
32
+
33
+ def dead_size
34
+ integer_command("ZCARD", "dead")
35
+ end
36
+
37
+ def processes_size
38
+ integer_command("SCARD", "processes")
39
+ end
40
+
41
+ def workers_size
42
+ ProcessSet.new(config: config).sum(&:busy)
43
+ end
44
+
45
+ def reset
46
+ config.redis_pool.call("DEL", "stat:processed", "stat:failed")
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def queues
53
+ Queue.all(config: config)
54
+ end
55
+
56
+ def integer(key)
57
+ Integer(config.redis_pool.call("GET", key) || 0)
58
+ end
59
+
60
+ def integer_command(*command)
61
+ Integer(config.redis_pool.call(*command))
62
+ end
63
+ end
64
+
65
+ class Queue
66
+ include Enumerable
67
+
68
+ attr_reader :name
69
+
70
+ def self.all(config: SolidJobs.config)
71
+ Array(config.redis_pool.call("SMEMBERS", "queues")).sort.map do |name|
72
+ new(name, config: config)
73
+ end
74
+ end
75
+
76
+ def initialize(name, config: SolidJobs.config)
77
+ @name = String(name)
78
+ @config = config
79
+ end
80
+
81
+ def each
82
+ return enum_for(:each) unless block_given?
83
+
84
+ Array(@config.redis_pool.call("LRANGE", key, 0, -1)).reverse_each do |raw|
85
+ yield JobRecord.new(raw, config: @config, container: self)
86
+ end
87
+ end
88
+
89
+ def size
90
+ Integer(@config.redis_pool.call("LLEN", key))
91
+ end
92
+ alias_method :length, :size
93
+
94
+ def latency
95
+ raw = @config.redis_pool.call("LINDEX", key, -1)
96
+ return 0.0 unless raw
97
+
98
+ payload = JSON.parse(raw)
99
+ enqueued_at = payload["enqueued_at"] || payload["created_at"]
100
+ return 0.0 unless enqueued_at
101
+
102
+ [Time.now.to_f - Float(enqueued_at) / 1_000, 0.0].max
103
+ end
104
+
105
+ def clear
106
+ @config.redis_pool.call("DEL", key)
107
+ self
108
+ end
109
+
110
+ def delete
111
+ @config.redis_pool.pipelined do |pipeline|
112
+ pipeline.call("DEL", key)
113
+ pipeline.call("SREM", "queues", name)
114
+ end
115
+ self
116
+ end
117
+
118
+ def paused?
119
+ Array(@config.redis_pool.call("SISMEMBER", "paused", name)).first.to_i == 1
120
+ end
121
+
122
+ def pause
123
+ @config.redis_pool.call("SADD", "paused", name)
124
+ self
125
+ end
126
+
127
+ def unpause
128
+ @config.redis_pool.call("SREM", "paused", name)
129
+ self
130
+ end
131
+
132
+ private
133
+
134
+ def key
135
+ "queue:#{name}"
136
+ end
137
+ end
138
+
139
+ class JobRecord
140
+ attr_reader :item, :score
141
+
142
+ def initialize(raw, score: nil, config: SolidJobs.config, container: nil)
143
+ @raw = raw
144
+ @item = JSON.parse(raw)
145
+ @score = Float(score) if score
146
+ @config = config
147
+ @container = container
148
+ end
149
+
150
+ def jid
151
+ item["jid"]
152
+ end
153
+
154
+ def queue
155
+ item["queue"]
156
+ end
157
+
158
+ def klass
159
+ item["class"]
160
+ end
161
+
162
+ def args
163
+ item["args"]
164
+ end
165
+
166
+ def created_at
167
+ milliseconds_time(item["created_at"])
168
+ end
169
+
170
+ def enqueued_at
171
+ milliseconds_time(item["enqueued_at"])
172
+ end
173
+
174
+ def at
175
+ Time.at(score) if score
176
+ end
177
+
178
+ def delete
179
+ case @container
180
+ when SortedSet
181
+ @config.redis_pool.call("ZREM", @container.name, @raw)
182
+ when Queue
183
+ @config.redis_pool.call("LREM", "queue:#{@container.name}", 1, @raw)
184
+ else
185
+ false
186
+ end
187
+ end
188
+
189
+ def retry
190
+ delete
191
+ Client.new(config: @config).push(item)
192
+ end
193
+
194
+ private
195
+
196
+ def milliseconds_time(value)
197
+ Time.at(Float(value) / 1_000) if value
198
+ end
199
+ end
200
+
201
+ class SortedSet
202
+ include Enumerable
203
+
204
+ attr_reader :name
205
+
206
+ def initialize(name, config: SolidJobs.config)
207
+ @name = name
208
+ @config = config
209
+ end
210
+
211
+ def each
212
+ return enum_for(:each) unless block_given?
213
+
214
+ values = Array(@config.redis_pool.call("ZRANGE", name, 0, -1, "WITHSCORES"))
215
+ values.each_slice(2) do |raw, score|
216
+ yield JobRecord.new(raw, score: score, config: @config, container: self)
217
+ end
218
+ end
219
+
220
+ def size
221
+ Integer(@config.redis_pool.call("ZCARD", name))
222
+ end
223
+ alias_method :length, :size
224
+
225
+ def clear
226
+ @config.redis_pool.call("DEL", name)
227
+ self
228
+ end
229
+
230
+ def fetch(*jids)
231
+ wanted = jids.flatten
232
+ select { |job| wanted.include?(job.jid) }
233
+ end
234
+ end
235
+
236
+ class ScheduledSet < SortedSet
237
+ def initialize(config: SolidJobs.config)
238
+ super("schedule", config: config)
239
+ end
240
+ end
241
+
242
+ class RetrySet < SortedSet
243
+ def initialize(config: SolidJobs.config)
244
+ super("retry", config: config)
245
+ end
246
+
247
+ def retry_all
248
+ each.to_a.each(&:retry)
249
+ end
250
+ end
251
+
252
+ class DeadSet < SortedSet
253
+ def initialize(config: SolidJobs.config)
254
+ super("dead", config: config)
255
+ end
256
+
257
+ def retry_all
258
+ each.to_a.each(&:retry)
259
+ end
260
+ end
261
+
262
+ class Process
263
+ attr_reader :identity
264
+
265
+ def initialize(identity, config: SolidJobs.config)
266
+ @identity = identity
267
+ @config = config
268
+ fields = Array(@config.redis_pool.call("HGETALL", identity))
269
+ @attributes = fields.each_slice(2).to_h
270
+ @info = JSON.parse(@attributes.fetch("info", "{}"))
271
+ end
272
+
273
+ def hostname
274
+ @info["hostname"]
275
+ end
276
+
277
+ def pid
278
+ Integer(@info["pid"] || 0)
279
+ end
280
+
281
+ def queues
282
+ @info["queues"] || []
283
+ end
284
+
285
+ def busy
286
+ Integer(@attributes["busy"] || 0)
287
+ end
288
+
289
+ def concurrency
290
+ Integer(@attributes["concurrency"] || 0)
291
+ end
292
+
293
+ def quiet?
294
+ @attributes["quiet"] == "true"
295
+ end
296
+
297
+ def beat
298
+ Time.at(Float(@attributes["beat"])) if @attributes["beat"]
299
+ end
300
+
301
+ def quiet!
302
+ signal("TSTP")
303
+ end
304
+
305
+ def stop!
306
+ signal("TERM")
307
+ end
308
+
309
+ def dump_threads
310
+ signal("TTIN")
311
+ end
312
+
313
+ def workers
314
+ fields = Array(@config.redis_pool.call("HGETALL", "#{identity}:work"))
315
+ fields.each_slice(2).map do |processor_id, raw|
316
+ Work.new(identity, processor_id, raw)
317
+ end
318
+ end
319
+
320
+ private
321
+
322
+ def signal(name)
323
+ @config.redis_pool.call("LPUSH", "#{identity}-signals", name)
324
+ true
325
+ end
326
+ end
327
+
328
+ class ProcessSet
329
+ include Enumerable
330
+
331
+ def initialize(config: SolidJobs.config)
332
+ @config = config
333
+ end
334
+
335
+ def each
336
+ return enum_for(:each) unless block_given?
337
+
338
+ Array(@config.redis_pool.call("SMEMBERS", "processes")).each do |identity|
339
+ process = Process.new(identity, config: @config)
340
+ if process.beat
341
+ yield process
342
+ else
343
+ @config.redis_pool.call("SREM", "processes", identity)
344
+ end
345
+ end
346
+ end
347
+
348
+ def size
349
+ count
350
+ end
351
+ end
352
+
353
+ class Work
354
+ attr_reader :process_id, :processor_id
355
+
356
+ def initialize(process_id, processor_id, raw)
357
+ @process_id = process_id
358
+ @processor_id = processor_id
359
+ @attributes = JSON.parse(raw)
360
+ end
361
+
362
+ def queue
363
+ @attributes["queue"]
364
+ end
365
+
366
+ def payload
367
+ @attributes["payload"]
368
+ end
369
+
370
+ def run_at
371
+ Time.at(Float(@attributes["run_at"]))
372
+ end
373
+
374
+ def reservation_id
375
+ @attributes["reservation_id"]
376
+ end
377
+
378
+ def attempt
379
+ Integer(@attributes["attempt"] || 1)
380
+ end
381
+ end
382
+
383
+ class Reservation
384
+ attr_reader :attributes
385
+
386
+ def initialize(raw)
387
+ @attributes = JSON.parse(raw)
388
+ end
389
+
390
+ %w[job_id reservation_id process_id worker_id queue].each do |name|
391
+ define_method(name) { attributes[name] }
392
+ end
393
+
394
+ def attempt
395
+ Integer(attributes["attempt"])
396
+ end
397
+
398
+ def reserved_at
399
+ Time.at(Float(attributes["reserved_at"]))
400
+ end
401
+ end
402
+
403
+ class ReservationSet
404
+ include Enumerable
405
+
406
+ def initialize(config: SolidJobs.config)
407
+ @config = config
408
+ end
409
+
410
+ def each
411
+ return enum_for(:each) unless block_given?
412
+
413
+ keys.each do |key|
414
+ Array(@config.redis_pool.call("HVALS", key)).each do |raw|
415
+ yield Reservation.new(raw)
416
+ end
417
+ end
418
+ end
419
+
420
+ def find_job(job_id)
421
+ find { |reservation| reservation.job_id == job_id }
422
+ end
423
+
424
+ private
425
+
426
+ def keys
427
+ cursor = "0"
428
+ found = []
429
+ loop do
430
+ cursor, batch = @config.redis_pool.call(
431
+ "SCAN", cursor, "MATCH", "*:reservations", "COUNT", 100,
432
+ )
433
+ found.concat(batch)
434
+ break if cursor == "0"
435
+ end
436
+ found
437
+ end
438
+ end
439
+
440
+ class Workers
441
+ include Enumerable
442
+
443
+ def initialize(config: SolidJobs.config)
444
+ @processes = ProcessSet.new(config: config)
445
+ end
446
+
447
+ def each
448
+ return enum_for(:each) unless block_given?
449
+
450
+ @processes.each { |process| process.workers.each { |work| yield work } }
451
+ end
452
+
453
+ def size
454
+ count
455
+ end
456
+ end
457
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "yaml"
5
+
6
+ module SolidJobs
7
+ class CLI
8
+ SIGNALS = %w[INT TERM TSTP TTIN INFO].freeze
9
+
10
+ def initialize
11
+ @options = {queues: []}
12
+ end
13
+
14
+ def run(arguments = ARGV)
15
+ parse(arguments)
16
+ load_configuration
17
+ load_application
18
+ apply_options
19
+ server = Server.new(config: SolidJobs.config).start
20
+ read_io, write_io = IO.pipe
21
+ handlers = install_signal_handlers(write_io)
22
+ wait_for_shutdown(server, read_io)
23
+ server.stop
24
+ 0
25
+ ensure
26
+ handlers&.each { |signal, handler| Signal.trap(signal, handler) }
27
+ read_io&.close
28
+ write_io&.close
29
+ end
30
+
31
+ def parse(arguments)
32
+ parser.parse!(arguments)
33
+ @options
34
+ end
35
+
36
+ def parser
37
+ @parser ||= OptionParser.new do |options|
38
+ options.banner = "Usage: solid-jobs [options]"
39
+ options.on("-r", "--require PATH", "Require an application file") { |value| @options[:require] = value }
40
+ options.on("-C", "--config PATH", "Load YAML configuration") { |value| @options[:config] = value }
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) }
43
+ options.on("-e", "--environment NAME", "Application environment") { |value| @options[:environment] = value }
44
+ options.on("-t", "--timeout SECONDS", Float, "Graceful shutdown timeout") { |value| @options[:timeout] = value }
45
+ options.on("-v", "--version", "Print version") do
46
+ puts "solid-jobs #{VERSION}"
47
+ exit
48
+ end
49
+ options.on("-h", "--help", "Print help") do
50
+ puts options
51
+ exit
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def load_configuration
59
+ return unless @options[:config]
60
+
61
+ values = YAML.safe_load_file(@options[:config], permitted_classes: [Symbol], aliases: true) || {}
62
+ values = values.fetch(@options[:environment], values) if @options[:environment]
63
+ @options[:concurrency] ||= values["concurrency"]
64
+ @options[:timeout] ||= values["timeout"]
65
+ @options[:queues] = Array(values["queues"]).map { |value| queue(value) } if @options[:queues].empty?
66
+ if (url = values.dig("redis", "url") || values["redis_url"])
67
+ SolidJobs.config.redis = SolidRedis::Config.new(url: url)
68
+ end
69
+ end
70
+
71
+ def load_application
72
+ return unless @options[:require]
73
+
74
+ require File.expand_path(@options[:require])
75
+ end
76
+
77
+ def apply_options
78
+ ENV["RAILS_ENV"] = ENV["RACK_ENV"] = @options[:environment] if @options[:environment]
79
+ SolidJobs.config.concurrency = @options[:concurrency] if @options[:concurrency]
80
+ SolidJobs.config.queues = @options[:queues] unless @options[:queues].empty?
81
+ SolidJobs.config.shutdown_timeout = @options[:timeout] if @options[:timeout]
82
+ end
83
+
84
+ def install_signal_handlers(write_io)
85
+ SIGNALS.to_h do |signal|
86
+ previous = Signal.trap(signal) do
87
+ write_io.write_nonblock("#{signal}\n", exception: false)
88
+ end
89
+ [signal, previous]
90
+ rescue ArgumentError
91
+ [signal, "DEFAULT"]
92
+ end
93
+ end
94
+
95
+ def wait_for_shutdown(server, read_io)
96
+ loop do
97
+ if IO.select([read_io], nil, nil, 0.5)
98
+ signal = read_io.gets&.strip
99
+ return if handle_signal(server, signal)
100
+ end
101
+ remote = server.remote_signal
102
+ return if remote && handle_signal(server, remote)
103
+ end
104
+ end
105
+
106
+ def handle_signal(server, signal)
107
+ case signal
108
+ when "INT", "TERM"
109
+ true
110
+ when "TSTP"
111
+ server.quiet
112
+ false
113
+ when "TTIN", "INFO"
114
+ dump_threads
115
+ false
116
+ else
117
+ false
118
+ end
119
+ end
120
+
121
+ def dump_threads
122
+ Thread.list.each do |thread|
123
+ SolidJobs.config.logger.info(
124
+ "Thread #{thread.object_id}: #{Array(thread.backtrace).join("\n")}",
125
+ )
126
+ end
127
+ end
128
+
129
+ def queue(value)
130
+ name, weight = value.to_s.split(",", 2)
131
+ weight ? [name, Integer(weight)] : name
132
+ end
133
+ end
134
+ end
135
+