solid-jobs 0.1.3 → 0.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -4
  3. data/README.md +149 -118
  4. data/docs/reliability.md +73 -91
  5. data/exe/solid-jobs +2 -3
  6. data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +7 -8
  7. data/lib/solid_jobs/{config.rb → blueprint.rb} +51 -51
  8. data/lib/solid_jobs/catalog.rb +427 -0
  9. data/lib/solid_jobs/claim.rb +315 -0
  10. data/lib/solid_jobs/{server.rb → conductor.rb} +10 -10
  11. data/lib/solid_jobs/{cli.rb → console.rb} +12 -9
  12. data/lib/solid_jobs/{processor.rb → engine.rb} +33 -33
  13. data/lib/solid_jobs/errors.rb +1 -1
  14. data/lib/solid_jobs/executor.rb +34 -0
  15. data/lib/solid_jobs/failure_policy.rb +98 -0
  16. data/lib/solid_jobs/heartbeat.rb +15 -13
  17. data/lib/solid_jobs/integrity_check.rb +39 -36
  18. data/lib/solid_jobs/interceptor_registry.rb +57 -0
  19. data/lib/solid_jobs/keyspace.rb +42 -0
  20. data/lib/solid_jobs/{testing.rb → lab.rb} +19 -19
  21. data/lib/solid_jobs/publisher.rb +186 -0
  22. data/lib/solid_jobs/rails_adapter.rb +13 -0
  23. data/lib/solid_jobs/{rails.rb → railtie.rb} +1 -2
  24. data/lib/solid_jobs/recovery.rb +8 -7
  25. data/lib/solid_jobs/task.rb +147 -0
  26. data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
  27. data/lib/solid_jobs/utilities.rb +5 -11
  28. data/lib/solid_jobs/version.rb +1 -1
  29. data/lib/solid_jobs.rb +28 -27
  30. metadata +19 -18
  31. data/lib/solid_jobs/active_job.rb +0 -16
  32. data/lib/solid_jobs/api.rb +0 -457
  33. data/lib/solid_jobs/client.rb +0 -202
  34. data/lib/solid_jobs/fetch.rb +0 -310
  35. data/lib/solid_jobs/job.rb +0 -156
  36. data/lib/solid_jobs/middleware/chain.rb +0 -104
  37. data/lib/solid_jobs/retry_service.rb +0 -100
  38. data/lib/solid_jobs/worker.rb +0 -35
@@ -1,457 +0,0 @@
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
@@ -1,202 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
-
6
- module SolidJobs
7
- class Client
8
- DEFAULT_BATCH_SIZE = 1_000
9
- SCHEDULED_BATCH_SIZE = 100
10
-
11
- def self.push(item)
12
- new.push(item)
13
- end
14
-
15
- def self.push_bulk(item)
16
- new.push_bulk(item)
17
- end
18
-
19
- def initialize(config: nil, configuration: nil)
20
- selected = config || configuration || SolidJobs.config
21
- @config = selected.is_a?(SolidRedis::Config) ? Config.new(redis: selected) : selected
22
- end
23
-
24
- def push(item)
25
- payload = normalize_item(item)
26
- payload = invoke_middleware(payload)
27
- return unless payload
28
-
29
- verify_json!(payload)
30
- raw_push([payload])
31
- payload["jid"]
32
- end
33
-
34
- def push_bulk(item)
35
- raise InvalidJobError, "Job payload must be a Hash" unless item.is_a?(Hash)
36
-
37
- source = Utilities.stringify_hash_keys(item)
38
- args = source.delete("args")
39
- raise InvalidJobError, "Job 'args' must be an Array or Enumerable" unless args.respond_to?(:each)
40
-
41
- ats = source.delete("at")
42
- if ats && !ats.is_a?(Numeric) && !ats.is_a?(Array)
43
- raise InvalidJobError, "Job 'at' must be a Numeric or an Array"
44
- end
45
- if ats.is_a?(Array) && args.respond_to?(:size) && ats.size != args.size
46
- raise InvalidJobError, "Job 'at' Array must have the same size as 'args'"
47
- end
48
- batch_size = Integer(source.delete("batch_size") || (ats ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE))
49
- raise ArgumentError, "batch_size must be positive" unless batch_size.positive?
50
-
51
- explicit_jid = source.delete("jid")
52
- if explicit_jid && args.respond_to?(:size) && args.size > 1
53
- raise InvalidJobError, "Explicit jid is only supported for one bulk job"
54
- end
55
- base = normalize_item(source.merge("args" => []))
56
- base.delete("jid")
57
- result = []
58
- args.each_slice(batch_size).with_index do |batch, batch_index|
59
- payloads = []
60
- batch.each_with_index do |job_args, index|
61
- raise InvalidJobError, "Bulk job arguments must be Arrays" unless job_args.is_a?(Array)
62
-
63
- payload = base.merge(
64
- "args" => job_args,
65
- "jid" => explicit_jid || SecureRandom.hex(12),
66
- )
67
- payload["at"] = ats.is_a?(Array) ? ats[batch_index * batch_size + index] : ats if ats
68
- normalized = invoke_middleware(payload)
69
- if normalized
70
- verify_json!(normalized)
71
- payloads << normalized
72
- result << normalized["jid"]
73
- else
74
- result << nil
75
- end
76
- end
77
- raw_push(payloads)
78
- end
79
- result
80
- end
81
-
82
- def raw_push(payloads)
83
- return true if payloads.empty?
84
-
85
- if defined?(Testing)
86
- case Testing.mode
87
- when :fake
88
- payloads.each do |payload|
89
- Testing.jobs_for(Utilities.constantize(payload["class"])) << payload
90
- end
91
- return true
92
- when :inline
93
- payloads.each { |payload| Testing.execute_inline(payload) }
94
- return true
95
- end
96
- end
97
-
98
- @config.redis_pool.pipelined do |pipeline|
99
- if payloads.all? { |payload| payload["at"] }
100
- payloads.each do |payload|
101
- score = payload.delete("at")
102
- pipeline.call("ZADD", "schedule", score, JSON.generate(payload))
103
- end
104
- elsif payloads.none? { |payload| payload["at"] }
105
- push_immediate(pipeline, payloads)
106
- else
107
- scheduled, immediate = payloads.partition { |payload| payload["at"] }
108
- scheduled.each do |payload|
109
- score = payload.delete("at")
110
- pipeline.call("ZADD", "schedule", score, JSON.generate(payload))
111
- end
112
- push_immediate(pipeline, immediate)
113
- end
114
- end
115
- true
116
- end
117
-
118
- private
119
-
120
- def normalize_item(item)
121
- raise InvalidJobError, "Job payload must be a Hash" unless item.is_a?(Hash)
122
-
123
- payload = @config.default_job_options.merge(Utilities.stringify_hash_keys(item))
124
- payload["class"] ||= payload.delete("job_class")
125
- payload["jid"] ||= payload.delete("job_id")
126
- job_class = payload["class"]
127
- args = payload["args"]
128
- unless job_class.is_a?(Class) || (job_class.is_a?(String) && !job_class.empty?)
129
- raise InvalidJobError, "Job must include a non-empty class"
130
- end
131
- raise InvalidJobError, "Job args must be an Array" unless args.is_a?(Array)
132
-
133
- payload["class"] = job_class.name if job_class.is_a?(Class)
134
- payload["queue"] = String(payload.fetch("queue"))
135
- payload["jid"] ||= SecureRandom.hex(12)
136
- payload["created_at"] ||= Utilities.realtime_milliseconds
137
- payload
138
- end
139
-
140
- def invoke_middleware(payload)
141
- job_class = payload["class"]
142
- @config.client_middleware.invoke(job_class, payload, payload["queue"], @config.redis_pool) do
143
- payload
144
- end
145
- end
146
-
147
- def push_immediate(pipeline, payloads)
148
- return if payloads.empty?
149
-
150
- groups = if payloads.all? { |payload| payload["queue"] == payloads.first["queue"] }
151
- [[payloads.first["queue"], payloads]]
152
- else
153
- payloads.group_by { |payload| payload["queue"] }
154
- end
155
- groups.each do |queue, jobs|
156
- pipeline.call("SADD", "queues", queue)
157
- now = Utilities.realtime_milliseconds
158
- encoded = jobs.map do |payload|
159
- payload["enqueued_at"] = now
160
- JSON.generate(payload)
161
- end
162
- pipeline.call("LPUSH", "queue:#{queue}", *encoded)
163
- end
164
- end
165
-
166
- def verify_json!(payload)
167
- invalid = find_invalid_json_value(payload["args"])
168
- return unless invalid
169
-
170
- message = "Job arguments must contain only JSON-native values with string hash keys; found #{invalid.inspect}"
171
- case @config.on_complex_arguments
172
- when :raise
173
- raise InvalidArgumentError, message
174
- when :warn
175
- @config.logger.warn(message)
176
- end
177
- end
178
-
179
- def find_invalid_json_value(value)
180
- case value
181
- when nil, true, false, String, Integer, Float
182
- nil
183
- when Array
184
- value.each do |nested|
185
- invalid = find_invalid_json_value(nested)
186
- return invalid if invalid
187
- end
188
- nil
189
- when Hash
190
- value.each do |key, nested|
191
- return key unless key.is_a?(String)
192
-
193
- invalid = find_invalid_json_value(nested)
194
- return invalid if invalid
195
- end
196
- nil
197
- else
198
- value
199
- end
200
- end
201
- end
202
- end