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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidJobs
6
+ class FailurePolicy < Service::Base
7
+
8
+ def call
9
+ payload = @payload.dup
10
+ now = Time.now
11
+ now_ms = Utilities.realtime_milliseconds
12
+ failure_count = Integer(payload.fetch("failure_count", 0)) + 1
13
+ payload["exception_message"] = @error.message.to_s
14
+ payload["exception_type"] = @error.class.name
15
+ payload["first_failed_ms"] ||= now_ms
16
+ payload["last_failed_ms"] = now_ms
17
+ payload["failure_count"] = failure_count
18
+ payload["channel"] = payload["retry_channel"] if payload["retry_channel"]
19
+
20
+ if retry_allowed?(payload, failure_count, now)
21
+ decision = retry_decision(payload, failure_count)
22
+ return nil if decision == :drop
23
+ return archive(payload, now.to_f) if decision == :archive
24
+
25
+ next_attempt = now.to_f + (decision || retry_delay(failure_count))
26
+ @config.redis_pool.call("ZADD", Keyspace::RETRIES, next_attempt, JSON.generate(payload))
27
+ append_message("retry scheduled")
28
+ next_attempt
29
+ else
30
+ invoke_final_failure(payload)
31
+ archive(payload, now.to_f)
32
+ append_message("task discarded")
33
+ nil
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def retry_allowed?(payload, failure_count, now)
40
+ if payload["retry_within"]
41
+ first_failure = Float(payload["first_failed_ms"]) / 1_000
42
+ now.to_f - first_failure < Float(payload["retry_within"])
43
+ else
44
+ maximum = Integer(payload.fetch("max_failures", 0))
45
+ failure_count <= maximum
46
+ end
47
+ end
48
+
49
+ def retry_delay(failure_count)
50
+ ceiling = [
51
+ Float(@config.retry_base_delay) * (2**(failure_count - 1)),
52
+ Float(@config.retry_max_delay),
53
+ ].min
54
+ (ceiling / 2) + (rand * ceiling / 2)
55
+ end
56
+
57
+ def retry_decision(payload, failure_count)
58
+ task = resolve_task(payload)
59
+ return unless task.respond_to?(:retry_delay_callback)
60
+
61
+ value = task.retry_delay_callback(failure_count, @error, payload)
62
+ return value if value.is_a?(Numeric) || %i[drop archive].include?(value)
63
+
64
+ nil
65
+ end
66
+
67
+ def invoke_final_failure(payload)
68
+ task = resolve_task(payload)
69
+ return unless task.respond_to?(:final_failure_callback)
70
+
71
+ task.final_failure_callback(payload, @error)
72
+ end
73
+
74
+ def resolve_task(payload)
75
+ Utilities.constantize(payload.fetch("task"))
76
+ rescue NameError
77
+ nil
78
+ end
79
+
80
+ def archive(payload, timestamp)
81
+ @config.redis_pool.pipelined do |pipeline|
82
+ pipeline.call("ZADD", Keyspace::DISCARDED, timestamp, JSON.generate(payload))
83
+ pipeline.call(
84
+ "ZREMRANGEBYSCORE",
85
+ Keyspace::DISCARDED,
86
+ "-inf",
87
+ timestamp - @config.discarded_retention,
88
+ )
89
+ pipeline.call(
90
+ "ZREMRANGEBYRANK",
91
+ Keyspace::DISCARDED,
92
+ 0,
93
+ -(@config.discarded_limit + 1),
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
@@ -21,37 +21,39 @@ module SolidJobs
21
21
  "hostname" => Socket.gethostname,
22
22
  "pid" => ::Process.pid,
23
23
  "started_at" => @started_at,
24
- "queues" => @config.queues,
24
+ "channels" => @config.channels,
25
25
  "labels" => [],
26
26
  "identity" => @identity,
27
27
  }
28
- busy = @config.redis_pool.call("HLEN", "#{@identity}:work")
28
+ node_key = Keyspace.node(@identity)
29
+ work_key = Keyspace.node_work(@identity)
30
+ busy = @config.redis_pool.call("HLEN", work_key)
29
31
  @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)
32
+ pipeline.call("INCRBY", Keyspace::PROCESSED, processed) if processed.positive?
33
+ pipeline.call("INCRBY", Keyspace::FAILED, failed) if failed.positive?
34
+ pipeline.call("SADD", Keyspace::NODES, @identity)
33
35
  pipeline.call(
34
36
  "HSET",
35
- @identity,
37
+ node_key,
36
38
  "info", JSON.generate(info),
37
39
  "beat", now,
38
40
  "busy", busy,
39
41
  "quiet", quiet ? "true" : "false",
40
42
  "concurrency", @concurrency,
41
43
  )
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)
44
+ pipeline.call("EXPIRE", node_key, TTL)
45
+ pipeline.call("DEL", work_key)
46
+ pipeline.call("HSET", work_key, *work.flatten(1)) unless work.empty?
47
+ pipeline.call("EXPIRE", work_key, TTL)
46
48
  end
47
49
  now
48
50
  end
49
51
 
50
52
  def cleanup
51
53
  @config.redis_pool.pipelined do |pipeline|
52
- pipeline.call("SREM", "processes", @identity)
53
- pipeline.call("DEL", @identity)
54
- pipeline.call("DEL", "#{@identity}:work")
54
+ pipeline.call("SREM", Keyspace::NODES, @identity)
55
+ pipeline.call("DEL", Keyspace.node(@identity))
56
+ pipeline.call("DEL", Keyspace.node_work(@identity))
55
57
  end
56
58
  ensure
57
59
  @config.close
@@ -5,10 +5,10 @@ require "set"
5
5
 
6
6
  module SolidJobs
7
7
  class IntegrityCheck
8
- ACTIVE_SETS = {
9
- "schedule" => "SCHEDULED",
10
- "retry" => "RETRY",
11
- "dead" => "DEAD",
8
+ ACTIVE_COLLECTIONS = {
9
+ Keyspace::PLANNED => "PLANNED",
10
+ Keyspace::RETRIES => "RETRYING",
11
+ Keyspace::DISCARDED => "DISCARDED",
12
12
  }.freeze
13
13
 
14
14
  Report = Struct.new(
@@ -51,10 +51,10 @@ module SolidJobs
51
51
  end
52
52
 
53
53
  def call
54
- read_queues
55
- read_sorted_sets
56
- reserved = read_reservations
57
- validate_reservation_metadata(reserved)
54
+ read_channels
55
+ read_timed_collections
56
+ claimed = read_claimed
57
+ validate_claim_metadata(claimed)
58
58
  @acked.each { |job_id| add_state(job_id, "ACKED", "acknowledged") }
59
59
 
60
60
  observed = @states.keys.to_set
@@ -71,56 +71,57 @@ module SolidJobs
71
71
 
72
72
  private
73
73
 
74
- def read_queues
75
- scan("queue:*").each do |key|
74
+ def read_channels
75
+ scan("#{Keyspace::PREFIX}:channel:*").each do |key|
76
76
  Array(@redis.call("LRANGE", key, 0, -1)).each_with_index do |raw, index|
77
77
  add_payload(raw, "READY", "#{key}[#{index}]")
78
78
  end
79
79
  end
80
80
  end
81
81
 
82
- def read_sorted_sets
83
- ACTIVE_SETS.each do |key, state|
82
+ def read_timed_collections
83
+ ACTIVE_COLLECTIONS.each do |key, state|
84
84
  Array(@redis.call("ZRANGE", key, 0, -1)).each_with_index do |raw, index|
85
85
  add_payload(raw, state, "#{key}[#{index}]")
86
86
  end
87
87
  end
88
88
  end
89
89
 
90
- def read_reservations
91
- scan("*:reserved:*").to_h do |key|
90
+ def read_claimed
91
+ scan("#{Keyspace::PREFIX}:node:*:claimed:*").to_h do |key|
92
92
  records = Array(@redis.call("LRANGE", key, 0, -1)).each_with_index.map do |raw, index|
93
93
  payload = parse_payload(raw, "#{key}[#{index}]")
94
- add_state(payload["jid"], "RESERVED", "#{key}[#{index}]") if payload
94
+ add_state(payload["id"], "CLAIMED", "#{key}[#{index}]") if payload
95
95
  payload
96
96
  end.compact
97
97
  [key, records]
98
98
  end
99
99
  end
100
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])
101
+ def validate_claim_metadata(claimed)
102
+ metadata = claim_metadata
103
+ claimed.each do |key, payloads|
104
+ node_key, executor_id = key.split(":claimed:", 2)
105
+ identity = node_key.delete_prefix("#{Keyspace::PREFIX}:node:")
106
+ entry = metadata.delete([identity, executor_id])
106
107
  if payloads.empty?
107
- @invalid_reservations << issue(key, "empty_reserved_list")
108
+ @invalid_reservations << issue(key, "empty_claimed_list")
108
109
  elsif payloads.length > 1
109
- @invalid_reservations << issue(key, "multiple_payloads_for_worker")
110
+ @invalid_reservations << issue(key, "multiple_tasks_for_executor")
110
111
  end
111
112
  payload = payloads.first
112
113
  unless entry
113
- @invalid_reservations << issue(key, "missing_metadata", job_id: payload&.[]("jid"))
114
+ @invalid_reservations << issue(key, "missing_metadata", job_id: payload&.[]("id"))
114
115
  next
115
116
  end
116
117
  next unless payload
117
118
 
118
119
  attributes = entry.fetch(:attributes)
119
120
  {
120
- "job_id" => payload["jid"],
121
- "process_id" => identity,
122
- "worker_id" => worker_id,
123
- "queue" => payload["queue"],
121
+ "task_id" => payload["id"],
122
+ "node_id" => identity,
123
+ "executor_id" => executor_id,
124
+ "channel" => payload["channel"],
124
125
  }.each do |field, expected|
125
126
  next if attributes[field].to_s == expected.to_s
126
127
 
@@ -138,14 +139,16 @@ module SolidJobs
138
139
  entry.fetch(:key),
139
140
  "metadata_without_payload",
140
141
  field: entry.fetch(:field),
141
- job_id: entry.fetch(:attributes)["job_id"],
142
+ job_id: entry.fetch(:attributes)["task_id"],
142
143
  )
143
144
  end
144
145
  end
145
146
 
146
- def reservation_metadata
147
- scan("*:reservations").each_with_object({}) do |key, entries|
148
- identity = key.delete_suffix(":reservations")
147
+ def claim_metadata
148
+ scan("#{Keyspace::PREFIX}:node:*:claims").each_with_object({}) do |key, entries|
149
+ identity = key
150
+ .delete_prefix("#{Keyspace::PREFIX}:node:")
151
+ .delete_suffix(":claims")
149
152
  hash_entries(key).each do |field, raw|
150
153
  attributes = parse_json(raw, "#{key}[#{field}]")
151
154
  next unless attributes
@@ -160,10 +163,10 @@ module SolidJobs
160
163
  end
161
164
 
162
165
  def dangling_attempts
163
- hash_entries("solid-jobs:attempts").filter_map do |job_id, attempt|
166
+ hash_entries(Keyspace::ATTEMPTS).filter_map do |job_id, attempt|
164
167
  next if @states[job_id]&.any? { |location| location.fetch(:state) != "ACKED" }
165
168
 
166
- {job_id: job_id, attempt: Integer(attempt), index: "solid-jobs:attempts"}
169
+ {job_id: job_id, attempt: Integer(attempt), index: Keyspace::ATTEMPTS}
167
170
  end
168
171
  end
169
172
 
@@ -177,15 +180,15 @@ module SolidJobs
177
180
 
178
181
  def add_payload(raw, state, location)
179
182
  payload = parse_payload(raw, location)
180
- add_state(payload["jid"], state, location) if payload
183
+ add_state(payload["id"], state, location) if payload
181
184
  end
182
185
 
183
186
  def parse_payload(raw, location)
184
187
  payload = parse_json(raw, location)
185
188
  return unless payload
186
- return payload if payload["jid"]
189
+ return payload if payload["id"]
187
190
 
188
- @malformed_payloads << {location: location, error: "missing jid", raw: raw}
191
+ @malformed_payloads << {location: location, error: "missing id", raw: raw}
189
192
  nil
190
193
  end
191
194
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ class InterceptorRegistry
5
+ Definition = Data.define(:type, :arguments)
6
+
7
+ include Enumerable
8
+
9
+ def initialize(definitions = [])
10
+ @definitions = definitions.map do |definition|
11
+ Definition.new(definition.type, definition.arguments.dup.freeze)
12
+ end
13
+ end
14
+
15
+ def each(&block)
16
+ @definitions.each(&block)
17
+ end
18
+
19
+ def use(type, *arguments)
20
+ discard(type)
21
+ @definitions = (@definitions + [Definition.new(type, arguments.freeze)]).freeze
22
+ self
23
+ end
24
+
25
+ def discard(type)
26
+ @definitions = @definitions.reject { |definition| definition.type == type }.freeze
27
+ self
28
+ end
29
+
30
+ def empty?
31
+ @definitions.empty?
32
+ end
33
+
34
+ def call(context, &operation)
35
+ continuation = operation
36
+ @definitions.reverse_each do |definition|
37
+ interceptor = definition.type.new(*definition.arguments)
38
+ downstream = continuation
39
+ continuation = -> { interceptor.around(context, &downstream) }
40
+ end
41
+ continuation.call
42
+ end
43
+
44
+ def export
45
+ Utilities.shareable_copy(
46
+ @definitions.map { |definition| [definition.type, definition.arguments] },
47
+ )
48
+ end
49
+
50
+ def import(records)
51
+ @definitions = records.map do |type, arguments|
52
+ Definition.new(type, arguments.freeze)
53
+ end.freeze
54
+ self
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ module Keyspace
5
+ PREFIX = "solid_jobs".freeze
6
+ CHANNELS = "#{PREFIX}:channels".freeze
7
+ PAUSED_CHANNELS = "#{PREFIX}:paused_channels".freeze
8
+ PLANNED = "#{PREFIX}:planned".freeze
9
+ RETRIES = "#{PREFIX}:retries".freeze
10
+ DISCARDED = "#{PREFIX}:discarded".freeze
11
+ NODES = "#{PREFIX}:nodes".freeze
12
+ ATTEMPTS = "#{PREFIX}:attempts".freeze
13
+ PROCESSED = "#{PREFIX}:metrics:processed".freeze
14
+ FAILED = "#{PREFIX}:metrics:failed".freeze
15
+
16
+ module_function
17
+
18
+ def channel(name)
19
+ "#{PREFIX}:channel:#{name}"
20
+ end
21
+
22
+ def node(identity)
23
+ "#{PREFIX}:node:#{identity}"
24
+ end
25
+
26
+ def node_work(identity)
27
+ "#{node(identity)}:work"
28
+ end
29
+
30
+ def node_signals(identity)
31
+ "#{node(identity)}:signals"
32
+ end
33
+
34
+ def claims(identity)
35
+ "#{node(identity)}:claims"
36
+ end
37
+
38
+ def claimed(identity, executor_id)
39
+ "#{node(identity)}:claimed:#{executor_id}"
40
+ end
41
+ end
42
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module SolidJobs
4
- module Testing
5
- MODES = %i[disable fake inline].freeze
6
- STORAGE_KEY = :solid_jobs_testing_jobs
6
+ module Lab
7
+ MODES = %i[disable capture execute].freeze
8
+ STORAGE_KEY = :solid_jobs_testing_captured
7
9
  MODE_KEY = :solid_jobs_testing_mode
8
10
 
9
11
  module_function
@@ -29,14 +31,14 @@ module SolidJobs
29
31
  end
30
32
  end
31
33
 
32
- def fake!
33
- testing!(:fake) { yield } if block_given?
34
- testing!(:fake) unless block_given?
34
+ def capture!
35
+ testing!(:capture) { yield } if block_given?
36
+ testing!(:capture) unless block_given?
35
37
  end
36
38
 
37
- def inline!
38
- testing!(:inline) { yield } if block_given?
39
- testing!(:inline) unless block_given?
39
+ def execute!
40
+ testing!(:execute) { yield } if block_given?
41
+ testing!(:execute) unless block_given?
40
42
  end
41
43
 
42
44
  def disable!
@@ -44,22 +46,20 @@ module SolidJobs
44
46
  testing!(:disable) unless block_given?
45
47
  end
46
48
 
47
- def jobs_for(job_class)
48
- storage[job_class.name] ||= []
49
+ def captured_for(task)
50
+ storage[task.name] ||= []
49
51
  end
50
52
 
51
53
  def clear_all
52
54
  storage.clear
53
55
  end
54
56
 
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
57
+ def execute(envelope)
58
+ normalized = Utilities.stringify_hash_keys(envelope)
59
+ normalized["task"] = normalized.fetch("task").name if normalized["task"].is_a?(Class)
60
+ normalized["channel"] ||= "default"
61
+ normalized["id"] ||= SecureRandom.uuid
62
+ Executor.new(redis_config: SolidJobs.config.redis_config).execute(normalized)
63
63
  end
64
64
 
65
65
  def storage
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module SolidJobs
7
+ class Publisher
8
+ DEFAULT_CHUNK_SIZE = 1_000
9
+ PLANNED_CHUNK_SIZE = 100
10
+ Publication = Data.define(:task, :envelope, :redis_pool)
11
+
12
+ def self.publish(envelope)
13
+ new.publish(envelope)
14
+ end
15
+
16
+ def self.publish_many(envelope)
17
+ new.publish_many(envelope)
18
+ end
19
+
20
+ def initialize(config: nil, configuration: nil)
21
+ selected = config || configuration || SolidJobs.config
22
+ @config = selected.is_a?(SolidRedis::Config) ? Blueprint.new(redis: selected) : selected
23
+ end
24
+
25
+ def publish(envelope)
26
+ normalized = normalize(envelope)
27
+ normalized = intercept(normalized)
28
+ return unless normalized
29
+
30
+ validate_arguments!(normalized.fetch("arguments"))
31
+ persist([normalized])
32
+ normalized.fetch("id")
33
+ end
34
+
35
+ def publish_many(envelope)
36
+ raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
37
+
38
+ source = Utilities.stringify_hash_keys(envelope)
39
+ argument_sets = source.delete("arguments")
40
+ unless argument_sets.respond_to?(:each)
41
+ raise InvalidJobError, "Task 'arguments' must be an Array or Enumerable"
42
+ end
43
+
44
+ run_times = source.delete("run_at")
45
+ unless run_times.nil? || run_times.is_a?(Numeric) || run_times.is_a?(Array)
46
+ raise InvalidJobError, "Task 'run_at' must be Numeric or an Array"
47
+ end
48
+ if run_times.is_a?(Array) && argument_sets.respond_to?(:size) && run_times.size != argument_sets.size
49
+ raise InvalidJobError, "Task 'run_at' Array must match the number of argument sets"
50
+ end
51
+
52
+ chunk_size = Integer(
53
+ source.delete("chunk_size") || (run_times ? PLANNED_CHUNK_SIZE : DEFAULT_CHUNK_SIZE),
54
+ )
55
+ raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive?
56
+
57
+ explicit_id = source.delete("id")
58
+ if explicit_id && argument_sets.respond_to?(:size) && argument_sets.size > 1
59
+ raise InvalidJobError, "An explicit id is only valid for a single task"
60
+ end
61
+
62
+ template = normalize(source.merge("arguments" => []))
63
+ template.delete("id")
64
+ ids = []
65
+ argument_sets.each_slice(chunk_size).with_index do |chunk, chunk_index|
66
+ envelopes = chunk.filter_map.with_index do |arguments, index|
67
+ raise InvalidJobError, "Each task's arguments must be an Array" unless arguments.is_a?(Array)
68
+
69
+ item = template.merge(
70
+ "arguments" => arguments,
71
+ "id" => explicit_id || SecureRandom.uuid,
72
+ )
73
+ item["run_at"] = run_times.is_a?(Array) ? run_times[chunk_index * chunk_size + index] : run_times if run_times
74
+ intercepted = intercept(item)
75
+ if intercepted
76
+ validate_arguments!(intercepted.fetch("arguments"))
77
+ ids << intercepted.fetch("id")
78
+ else
79
+ ids << nil
80
+ end
81
+ intercepted
82
+ end
83
+ persist(envelopes)
84
+ end
85
+ ids
86
+ end
87
+
88
+ def persist(envelopes)
89
+ return true if envelopes.empty?
90
+
91
+ if defined?(Lab)
92
+ case Lab.mode
93
+ when :capture
94
+ envelopes.each do |envelope|
95
+ Lab.captured_for(Utilities.constantize(envelope.fetch("task"))) << envelope
96
+ end
97
+ return true
98
+ when :execute
99
+ envelopes.each { |envelope| Lab.execute(envelope) }
100
+ return true
101
+ end
102
+ end
103
+
104
+ @config.redis_pool.pipelined do |pipeline|
105
+ planned, ready = envelopes.partition { |envelope| envelope["run_at"] }
106
+ planned.each do |envelope|
107
+ score = envelope.delete("run_at")
108
+ pipeline.call("ZADD", Keyspace::PLANNED, score, JSON.generate(envelope))
109
+ end
110
+ persist_ready(pipeline, ready)
111
+ end
112
+ true
113
+ end
114
+
115
+ private
116
+
117
+ def normalize(envelope)
118
+ raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
119
+
120
+ item = @config.default_task_options.merge(Utilities.stringify_hash_keys(envelope))
121
+ task = item["task"]
122
+ arguments = item["arguments"]
123
+ unless task.is_a?(Class) || (task.is_a?(String) && !task.empty?)
124
+ raise InvalidJobError, "Task envelope requires a non-empty task"
125
+ end
126
+ raise InvalidJobError, "Task arguments must be an Array" unless arguments.is_a?(Array)
127
+
128
+ item["task"] = task.name if task.is_a?(Class)
129
+ item["channel"] = String(item.fetch("channel"))
130
+ item["id"] ||= SecureRandom.uuid
131
+ item["created_ms"] ||= Utilities.realtime_milliseconds
132
+ item
133
+ end
134
+
135
+ def intercept(envelope)
136
+ context = Publication.new(envelope.fetch("task"), envelope, @config.redis_pool)
137
+ @config.publish_interceptors.call(context) { envelope }
138
+ end
139
+
140
+ def persist_ready(pipeline, envelopes)
141
+ envelopes.group_by { |envelope| envelope.fetch("channel") }.each do |channel, group|
142
+ pipeline.call("SADD", Keyspace::CHANNELS, channel)
143
+ queued_ms = Utilities.realtime_milliseconds
144
+ encoded = group.map do |envelope|
145
+ envelope["queued_ms"] = queued_ms
146
+ JSON.generate(envelope)
147
+ end
148
+ pipeline.call("LPUSH", Keyspace.channel(channel), *encoded)
149
+ end
150
+ end
151
+
152
+ def validate_arguments!(arguments)
153
+ invalid = invalid_json_value(arguments)
154
+ return unless invalid
155
+
156
+ message = "Task arguments must contain JSON-native values with string hash keys; found #{invalid.inspect}"
157
+ case @config.on_complex_arguments
158
+ when :raise then raise InvalidArgumentError, message
159
+ when :warn then @config.logger.warn(message)
160
+ end
161
+ end
162
+
163
+ def invalid_json_value(value)
164
+ case value
165
+ when nil, true, false, String, Integer, Float
166
+ nil
167
+ when Array
168
+ value.each do |nested|
169
+ invalid = invalid_json_value(nested)
170
+ return invalid if invalid
171
+ end
172
+ nil
173
+ when Hash
174
+ value.each do |key, nested|
175
+ return key unless key.is_a?(String)
176
+
177
+ invalid = invalid_json_value(nested)
178
+ return invalid if invalid
179
+ end
180
+ nil
181
+ else
182
+ value
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ module RailsAdapter
5
+ class AdapterTask
6
+ include SolidJobs::Task
7
+
8
+ def execute_task(job_data)
9
+ ::ActiveJob::Base.execute(job_data.merge("provider_job_id" => task_id))
10
+ end
11
+ end
12
+ end
13
+ end