solid-jobs 0.1.3 → 0.2.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.
@@ -163,7 +163,7 @@ module SolidJobs
163
163
  started = SolidJobs::StartupBarrier.boot(ready) do
164
164
  local_config = SolidJobs::Config.from_ractor_snapshot(settings)
165
165
  SolidJobs.use_config(local_config)
166
- scheduler = SolidJobs::Scheduler.new(local_config)
166
+ scheduler = SolidJobs::Timer.new(local_config)
167
167
  local_config.redis_pool.call("PING")
168
168
  end
169
169
  next :aborted unless started
@@ -265,7 +265,7 @@ module SolidJobs
265
265
  end
266
266
 
267
267
  def remote_signal
268
- config.redis_pool.call("RPOP", "#{identity}-signals")
268
+ config.redis_pool.call("RPOP", Keyspace.node_signals(identity))
269
269
  end
270
270
 
271
271
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ module Task
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ base.include(InstanceMethods)
8
+ base.task_options
9
+ end
10
+
11
+ module InstanceMethods
12
+ attr_accessor :task_id
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 task_options(options = nil)
30
+ if options
31
+ merged = current_task_options.merge(Utilities.stringify_keys(options))
32
+ @task_options = Utilities.shareable_copy(merged)
33
+ else
34
+ @task_options ||= Utilities.shareable_copy(SolidJobs.config.default_task_options)
35
+ end
36
+ end
37
+
38
+ def current_task_options
39
+ task_options
40
+ end
41
+
42
+ def retry_delay(&block)
43
+ define_singleton_method(:retry_delay_callback, &block) if block
44
+ method(:retry_delay_callback) if respond_to?(:retry_delay_callback)
45
+ end
46
+
47
+ def after_final_failure(&block)
48
+ define_singleton_method(:final_failure_callback, &block) if block
49
+ method(:final_failure_callback) if respond_to?(:final_failure_callback)
50
+ end
51
+
52
+ def enqueue(*arguments)
53
+ publish("task" => self, "arguments" => arguments)
54
+ end
55
+
56
+ def enqueue_many(argument_sets, chunk_size: Publisher::DEFAULT_CHUNK_SIZE)
57
+ Publisher.new.publish_many(
58
+ current_task_options.merge(
59
+ "task" => self,
60
+ "arguments" => argument_sets,
61
+ "chunk_size" => chunk_size,
62
+ ),
63
+ )
64
+ end
65
+
66
+ def enqueue_after(delay, *arguments)
67
+ enqueue_at(Time.now.to_f + Float(delay), *arguments)
68
+ end
69
+
70
+ def enqueue_at(time, *arguments)
71
+ timestamp = time.is_a?(Time) ? time.to_f : Float(time)
72
+ publish("task" => self, "arguments" => arguments, "run_at" => timestamp)
73
+ end
74
+
75
+ def execute(*arguments)
76
+ Testing.execute(current_task_options.merge("task" => self, "arguments" => arguments))
77
+ end
78
+
79
+ def with_options(options)
80
+ Submission.new(self, options)
81
+ end
82
+
83
+ def captured
84
+ Testing.captured_for(self)
85
+ end
86
+
87
+ def clear_captured
88
+ captured.clear
89
+ end
90
+
91
+ def execute_next
92
+ envelope = captured.shift
93
+ raise EmptyQueueError, "No captured task for #{name}" unless envelope
94
+
95
+ Testing.execute(envelope)
96
+ end
97
+
98
+ private
99
+
100
+ def publish(envelope)
101
+ Publisher.new.publish(current_task_options.merge(envelope))
102
+ end
103
+ end
104
+
105
+ class Submission
106
+ def initialize(task, options)
107
+ @task = task
108
+ @options = Utilities.stringify_keys(options)
109
+ end
110
+
111
+ def enqueue(*arguments)
112
+ return @task.execute(*arguments) if @options.delete("execute")
113
+
114
+ if (delay = @options.delete("delay"))
115
+ enqueue_after(delay, *arguments)
116
+ elsif (time = @options.delete("run_at"))
117
+ enqueue_at(time, *arguments)
118
+ else
119
+ publish(arguments)
120
+ end
121
+ end
122
+
123
+ def enqueue_after(delay, *arguments)
124
+ enqueue_at(Time.now.to_f + Float(delay), *arguments)
125
+ end
126
+
127
+ def enqueue_at(time, *arguments)
128
+ timestamp = time.is_a?(Time) ? time.to_f : Float(time)
129
+ publish(arguments, "run_at" => timestamp)
130
+ end
131
+
132
+ private
133
+
134
+ def publish(arguments, extra = {})
135
+ Publisher.new.publish(
136
+ @task.current_task_options.merge(@options).merge(
137
+ extra,
138
+ "task" => @task,
139
+ "arguments" => arguments,
140
+ ),
141
+ )
142
+ end
143
+ end
144
+ end
145
+
146
+ class EmptyQueueError < Error; end
147
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module SolidJobs
4
6
  module Testing
5
- MODES = %i[disable fake inline].freeze
6
- STORAGE_KEY = :solid_jobs_testing_jobs
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
@@ -3,8 +3,8 @@
3
3
  require "json"
4
4
 
5
5
  module SolidJobs
6
- class Scheduler
7
- SETS = %w[retry schedule].freeze
6
+ class Timer
7
+ COLLECTIONS = [Keyspace::RETRIES, Keyspace::PLANNED].freeze
8
8
  POP_DUE = <<~LUA.freeze
9
9
  local jobs = redis.call("zrange", KEYS[1], "-inf", ARGV[1], "byscore", "limit", 0, 1)
10
10
  if jobs[1] then
@@ -15,14 +15,14 @@ module SolidJobs
15
15
 
16
16
  def initialize(config)
17
17
  @config = config
18
- @client = Client.new(config: config)
18
+ @publisher = Publisher.new(config: config)
19
19
  end
20
20
 
21
21
  def enqueue_due(now = Time.now.to_f)
22
22
  count = 0
23
- SETS.each do |set|
24
- while (raw = pop_due(set, now))
25
- @client.push(JSON.parse(raw))
23
+ COLLECTIONS.each do |collection|
24
+ while (raw = pop_due(collection, now))
25
+ @publisher.publish(JSON.parse(raw))
26
26
  count += 1
27
27
  end
28
28
  end
@@ -45,8 +45,8 @@ module SolidJobs
45
45
 
46
46
  private
47
47
 
48
- def pop_due(set, now)
49
- @config.redis_pool.call("EVAL", POP_DUE, 1, set, now)
48
+ def pop_due(collection, now)
49
+ @config.redis_pool.call("EVAL", POP_DUE, 1, collection, now)
50
50
  end
51
51
 
52
52
  def randomized_interval
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidJobs
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/solid_jobs.rb CHANGED
@@ -7,17 +7,18 @@ require "solid_redis"
7
7
  require_relative "solid_jobs/version"
8
8
  require_relative "solid_jobs/errors"
9
9
  require_relative "solid_jobs/utilities"
10
+ require_relative "solid_jobs/keyspace"
10
11
  require_relative "solid_jobs/ractor_support"
11
12
  require_relative "solid_jobs/startup_barrier"
12
- require_relative "solid_jobs/middleware/chain"
13
+ require_relative "solid_jobs/interceptor_registry"
13
14
  require_relative "solid_jobs/config"
14
- require_relative "solid_jobs/client"
15
+ require_relative "solid_jobs/publisher"
15
16
  require_relative "solid_jobs/testing"
16
- require_relative "solid_jobs/job"
17
- require_relative "solid_jobs/worker"
18
- require_relative "solid_jobs/fetch"
19
- require_relative "solid_jobs/retry_service"
20
- require_relative "solid_jobs/scheduler"
17
+ require_relative "solid_jobs/task"
18
+ require_relative "solid_jobs/executor"
19
+ require_relative "solid_jobs/claim"
20
+ require_relative "solid_jobs/failure_policy"
21
+ require_relative "solid_jobs/timer"
21
22
  require_relative "solid_jobs/processor"
22
23
  require_relative "solid_jobs/heartbeat"
23
24
  require_relative "solid_jobs/recovery"
@@ -57,22 +58,22 @@ module SolidJobs
57
58
  Testing.testing!(mode, &block)
58
59
  end
59
60
 
60
- def enqueue(job_class, args, queue: "default", **options)
61
- Client.push(
61
+ def enqueue(task, arguments, channel: "default", **options)
62
+ Publisher.publish(
62
63
  options.merge(
63
- "class" => job_class,
64
- "args" => args,
65
- "queue" => queue,
64
+ "task" => task,
65
+ "arguments" => arguments,
66
+ "channel" => channel,
66
67
  ),
67
68
  )
68
69
  end
69
70
 
70
- def enqueue_bulk(job_class, arguments, queue: "default", **options)
71
- Client.push_bulk(
71
+ def enqueue_many(task, argument_sets, channel: "default", **options)
72
+ Publisher.publish_many(
72
73
  options.merge(
73
- "class" => job_class,
74
- "args" => arguments,
75
- "queue" => queue,
74
+ "task" => task,
75
+ "arguments" => argument_sets,
76
+ "channel" => channel,
76
77
  ),
77
78
  )
78
79
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid-jobs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Vandenbogaerde
@@ -105,8 +105,8 @@ dependencies:
105
105
  - - "~>"
106
106
  - !ruby/object:Gem::Version
107
107
  version: '13.0'
108
- description: Ractor-local job clients and workers with Sidekiq-compatible Redis payloads,
109
- queues, scheduling, and retries.
108
+ description: A Ractor-local Redis task runtime with its own envelope, channels, claims,
109
+ interceptors, scheduling, and failure handling.
110
110
  executables:
111
111
  - solid-jobs
112
112
  extensions: []
@@ -121,27 +121,28 @@ files:
121
121
  - lib/solid_jobs.rb
122
122
  - lib/solid_jobs/active_job.rb
123
123
  - lib/solid_jobs/api.rb
124
+ - lib/solid_jobs/claim.rb
124
125
  - lib/solid_jobs/cli.rb
125
- - lib/solid_jobs/client.rb
126
126
  - lib/solid_jobs/config.rb
127
127
  - lib/solid_jobs/errors.rb
128
- - lib/solid_jobs/fetch.rb
128
+ - lib/solid_jobs/executor.rb
129
+ - lib/solid_jobs/failure_policy.rb
129
130
  - lib/solid_jobs/heartbeat.rb
130
131
  - lib/solid_jobs/integrity_check.rb
131
- - lib/solid_jobs/job.rb
132
- - lib/solid_jobs/middleware/chain.rb
132
+ - lib/solid_jobs/interceptor_registry.rb
133
+ - lib/solid_jobs/keyspace.rb
133
134
  - lib/solid_jobs/processor.rb
135
+ - lib/solid_jobs/publisher.rb
134
136
  - lib/solid_jobs/ractor_support.rb
135
137
  - lib/solid_jobs/rails.rb
136
138
  - lib/solid_jobs/recovery.rb
137
- - lib/solid_jobs/retry_service.rb
138
- - lib/solid_jobs/scheduler.rb
139
139
  - lib/solid_jobs/server.rb
140
140
  - lib/solid_jobs/startup_barrier.rb
141
+ - lib/solid_jobs/task.rb
141
142
  - lib/solid_jobs/testing.rb
143
+ - lib/solid_jobs/timer.rb
142
144
  - lib/solid_jobs/utilities.rb
143
145
  - lib/solid_jobs/version.rb
144
- - lib/solid_jobs/worker.rb
145
146
  homepage: https://github.com/nicolasva/solid-jobs
146
147
  licenses:
147
148
  - MIT
@@ -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