solid_queue 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 952d693063dae16e88a88acb2f8d77f396472cb29811e7ab2c47c06fc400cf10
4
- data.tar.gz: 338ad6a0057939a54997cdef138fa5e62e974112625c43fd65240354a0ba760a
3
+ metadata.gz: e62ec09bbed1f2bcb96da96865c3516adbc254ad7cea234d3b6573ea001c758e
4
+ data.tar.gz: d78e7543194f3f6470bdb80c52ffca78c520a602d93423185c02ba82475bbda6
5
5
  SHA512:
6
- metadata.gz: b0bddd34b216770c9e0658bb620ae8801ab500074692417aeb0907c1f22f4f9e40a6233266aa69c1aa2b015dc294864d529659f0ee4adbdfdbef647d03fb4d35
7
- data.tar.gz: f2323054f8fdc5ee686f738d2a8359b23fe88f00b1b376cc18e99588bacc9df3be2e3a8bb660ce31db854f0fcca91560ad4d703a737b1d05c3a1dea7817c10a2
6
+ metadata.gz: e7cdceced9162911efdd0f2020042e45f7c73c74c506cf1d4958211b2cc34f02e96553d9910b3d3cca925f00e10960837be4872a33c3e44388e0b6c37cbcec31
7
+ data.tar.gz: d9a17282687ef9feaa4b357124411e9bd088bebb8c0ce6fd67ccf887e4d9cf20a431882e84b40f68ff5ca05a008258bf4d64b6ab86b675cb581be65154d1e39e
@@ -35,6 +35,18 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
35
35
  end
36
36
  end
37
37
 
38
+ def fail_all_with(error)
39
+ SolidQueue.instrument(:fail_many_claimed) do |payload|
40
+ includes(:job).tap do |executions|
41
+ payload[:size] = executions.size
42
+ payload[:process_ids] = executions.map(&:process_id).uniq
43
+ payload[:job_ids] = executions.map(&:job_id).uniq
44
+
45
+ executions.each { |execution| execution.failed_with(error) }
46
+ end
47
+ end
48
+ end
49
+
38
50
  def discard_all_in_batches(*)
39
51
  raise UndiscardableError, "Can't discard jobs in progress"
40
52
  end
@@ -69,6 +81,13 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
69
81
  raise UndiscardableError, "Can't discard a job in progress"
70
82
  end
71
83
 
84
+ def failed_with(error)
85
+ transaction do
86
+ job.failed_with(error)
87
+ destroy!
88
+ end
89
+ end
90
+
72
91
  private
73
92
  def execute
74
93
  ActiveJob::Base.execute(job.arguments)
@@ -83,11 +102,4 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
83
102
  destroy!
84
103
  end
85
104
  end
86
-
87
- def failed_with(error)
88
- transaction do
89
- job.failed_with(error)
90
- destroy!
91
- end
92
- end
93
105
  end
@@ -11,6 +11,12 @@ module SolidQueue
11
11
  after_destroy -> { claimed_executions.release_all }, if: :claims_executions?
12
12
  end
13
13
 
14
+ def fail_all_claimed_executions_with(error)
15
+ if claims_executions?
16
+ claimed_executions.fail_all_with(error)
17
+ end
18
+ end
19
+
14
20
  private
15
21
  def claims_executions?
16
22
  kind == "Worker"
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueue
4
+ class ProcessPrunedError < RuntimeError
5
+ def initialize(last_heartbeat_at)
6
+ super("Process was found dead and pruned (last heartbeat at: #{last_heartbeat_at}")
7
+ end
8
+ end
9
+
4
10
  class Process
5
11
  module Prunable
6
12
  extend ActiveSupport::Concern
@@ -15,11 +21,18 @@ module SolidQueue
15
21
  prunable.non_blocking_lock.find_in_batches(batch_size: 50) do |batch|
16
22
  payload[:size] += batch.size
17
23
 
18
- batch.each { |process| process.deregister(pruned: true) }
24
+ batch.each(&:prune)
19
25
  end
20
26
  end
21
27
  end
22
28
  end
29
+
30
+ def prune
31
+ error = ProcessPrunedError.new(last_heartbeat_at)
32
+ fail_all_claimed_executions_with(error)
33
+
34
+ deregister(pruned: true)
35
+ end
23
36
  end
24
37
  end
25
38
  end
@@ -13,10 +13,10 @@ class SolidQueue::Process < SolidQueue::Record
13
13
  create!(attributes.merge(last_heartbeat_at: Time.current)).tap do |process|
14
14
  payload[:process_id] = process.id
15
15
  end
16
+ rescue Exception => error
17
+ payload[:error] = error
18
+ raise
16
19
  end
17
- rescue Exception => error
18
- SolidQueue.instrument :register_process, **attributes.merge(error: error)
19
- raise
20
20
  end
21
21
 
22
22
  def heartbeat
@@ -25,8 +25,6 @@ class SolidQueue::Process < SolidQueue::Record
25
25
 
26
26
  def deregister(pruned: false)
27
27
  SolidQueue.instrument :deregister_process, process: self, pruned: pruned do |payload|
28
- payload[:claimed_size] = claimed_executions.size if claims_executions?
29
-
30
28
  destroy!
31
29
  rescue Exception => error
32
30
  payload[:error] = error
@@ -25,7 +25,7 @@ module SolidQueue
25
25
  def record(task_key, run_at, &block)
26
26
  transaction do
27
27
  block.call.tap do |active_job|
28
- if active_job
28
+ if active_job && active_job.successfully_enqueued?
29
29
  create_or_insert!(job_id: active_job.provider_job_id, task_key: task_key, run_at: run_at)
30
30
  end
31
31
  end
@@ -43,7 +43,7 @@ module SolidQueue
43
43
  def enqueue(at:)
44
44
  SolidQueue.instrument(:enqueue_recurring_task, task: key, at: at) do |payload|
45
45
  active_job = if using_solid_queue_adapter?
46
- perform_later_and_record(run_at: at)
46
+ enqueue_and_record(run_at: at)
47
47
  else
48
48
  payload[:other_adapter] = true
49
49
 
@@ -87,8 +87,15 @@ module SolidQueue
87
87
  job_class.queue_adapter_name.inquiry.solid_queue?
88
88
  end
89
89
 
90
- def perform_later_and_record(run_at:)
91
- RecurringExecution.record(key, run_at) { perform_later }
90
+ def enqueue_and_record(run_at:)
91
+ RecurringExecution.record(key, run_at) do
92
+ job_class.new(*arguments_with_kwargs).tap do |active_job|
93
+ active_job.run_callbacks(:enqueue) do
94
+ Job.enqueue(active_job)
95
+ end
96
+ active_job.successfully_enqueued = true
97
+ end
98
+ end
92
99
  end
93
100
 
94
101
  def perform_later(&block)
@@ -0,0 +1,5 @@
1
+ class AddNameToProcesses < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :solid_queue_processes, :name, :string
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ class MakeNameNotNull < ActiveRecord::Migration[7.1]
2
+ def up
3
+ SolidQueue::Process.where(name: nil).find_each do |process|
4
+ process.name ||= [ process.kind.downcase, SecureRandom.hex(10) ].join("-")
5
+ process.save!
6
+ end
7
+
8
+ change_column :solid_queue_processes, :name, :string, null: false
9
+ add_index :solid_queue_processes, [ :name, :supervisor_id ], unique: true
10
+ end
11
+
12
+ def down
13
+ remove_index :solid_queue_processes, [ :name, :supervisor_id ]
14
+ change_column :solid_queue_processes, :name, :string, null: false
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ class ChangeSolidQueueRecurringTasksStaticToNotNull < ActiveRecord::Migration[7.1]
2
+ def change
3
+ change_column_null :solid_queue_recurring_tasks, :static, false, true
4
+ end
5
+ end
@@ -2,6 +2,12 @@
2
2
 
3
3
  module SolidQueue
4
4
  class Configuration
5
+ class Process < Struct.new(:kind, :attributes)
6
+ def instantiate
7
+ "SolidQueue::#{kind.to_s.titleize}".safe_constantize.new(**attributes)
8
+ end
9
+ end
10
+
5
11
  WORKER_DEFAULTS = {
6
12
  queues: "*",
7
13
  threads: 3,
@@ -22,28 +28,10 @@ module SolidQueue
22
28
  @raw_config = config_from(load_from)
23
29
  end
24
30
 
25
- def processes
31
+ def configured_processes
26
32
  dispatchers + workers
27
33
  end
28
34
 
29
- def workers
30
- workers_options.flat_map do |worker_options|
31
- processes = if mode.fork?
32
- worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
33
- else
34
- WORKER_DEFAULTS[:processes]
35
- end
36
- processes.times.map { Worker.new(**worker_options.with_defaults(WORKER_DEFAULTS)) }
37
- end
38
- end
39
-
40
- def dispatchers
41
- dispatchers_options.map do |dispatcher_options|
42
- recurring_tasks = parse_recurring_tasks dispatcher_options[:recurring_tasks]
43
- Dispatcher.new **dispatcher_options.merge(recurring_tasks: recurring_tasks).with_defaults(DISPATCHER_DEFAULTS)
44
- end
45
- end
46
-
47
35
  def max_number_of_threads
48
36
  # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
49
37
  workers_options.map { |options| options[:threads] }.max + 2
@@ -54,6 +42,24 @@ module SolidQueue
54
42
 
55
43
  DEFAULT_CONFIG_FILE_PATH = "config/solid_queue.yml"
56
44
 
45
+ def workers
46
+ workers_options.flat_map do |worker_options|
47
+ processes = if mode.fork?
48
+ worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
49
+ else
50
+ WORKER_DEFAULTS[:processes]
51
+ end
52
+ processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
53
+ end
54
+ end
55
+
56
+ def dispatchers
57
+ dispatchers_options.map do |dispatcher_options|
58
+ recurring_tasks = parse_recurring_tasks dispatcher_options[:recurring_tasks]
59
+ Process.new :dispatcher, dispatcher_options.merge(recurring_tasks: recurring_tasks).with_defaults(DISPATCHER_DEFAULTS)
60
+ end
61
+ end
62
+
57
63
  def config_from(file_or_hash, env: Rails.env)
58
64
  config = load_config_from(file_or_hash)
59
65
  config[env.to_sym] ? config[env.to_sym] : config
@@ -12,11 +12,15 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
12
12
  end
13
13
 
14
14
  def release_many_claimed(event)
15
- debug formatted_event(event, action: "Release claimed jobs", **event.payload.slice(:size))
15
+ info formatted_event(event, action: "Release claimed jobs", **event.payload.slice(:size))
16
+ end
17
+
18
+ def fail_many_claimed(event)
19
+ warn formatted_event(event, action: "Fail claimed jobs", **event.payload.slice(:job_ids, :process_ids))
16
20
  end
17
21
 
18
22
  def release_claimed(event)
19
- debug formatted_event(event, action: "Release claimed job", **event.payload.slice(:job_id, :process_id))
23
+ info formatted_event(event, action: "Release claimed job", **event.payload.slice(:job_id, :process_id))
20
24
  end
21
25
 
22
26
  def retry_all(event)
@@ -63,7 +67,8 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
63
67
  attributes = {
64
68
  pid: process.pid,
65
69
  hostname: process.hostname,
66
- process_id: process.process_id
70
+ process_id: process.process_id,
71
+ name: process.name
67
72
  }.merge(process.metadata)
68
73
 
69
74
  info formatted_event(event, action: "Started #{process.kind}", **attributes)
@@ -75,7 +80,8 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
75
80
  attributes = {
76
81
  pid: process.pid,
77
82
  hostname: process.hostname,
78
- process_id: process.process_id
83
+ process_id: process.process_id,
84
+ name: process.name
79
85
  }.merge(process.metadata)
80
86
 
81
87
  info formatted_event(event, action: "Shutdown #{process.kind}", **attributes)
@@ -83,7 +89,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
83
89
 
84
90
  def register_process(event)
85
91
  process_kind = event.payload[:kind]
86
- attributes = event.payload.slice(:pid, :hostname, :process_id)
92
+ attributes = event.payload.slice(:pid, :hostname, :process_id, :name)
87
93
 
88
94
  if error = event.payload[:error]
89
95
  warn formatted_event(event, action: "Error registering #{process_kind}", **attributes.merge(error: formatted_error(error)))
@@ -99,6 +105,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
99
105
  process_id: process.id,
100
106
  pid: process.pid,
101
107
  hostname: process.hostname,
108
+ name: process.name,
102
109
  last_heartbeat_at: process.last_heartbeat_at.iso8601,
103
110
  claimed_size: event.payload[:claimed_size],
104
111
  pruned: event.payload[:pruned]
@@ -147,7 +154,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
147
154
  termsig: status.termsig
148
155
 
149
156
  if replaced_fork = event.payload[:fork]
150
- info formatted_event(event, action: "Replaced terminated #{replaced_fork.kind}", **attributes.merge(hostname: replaced_fork.hostname))
157
+ info formatted_event(event, action: "Replaced terminated #{replaced_fork.kind}", **attributes.merge(hostname: replaced_fork.hostname, name: replaced_fork.name))
151
158
  else
152
159
  warn formatted_event(event, action: "Tried to replace forked process but it had already died", **attributes)
153
160
  end
@@ -6,6 +6,12 @@ module SolidQueue
6
6
  include Callbacks # Defines callbacks needed by other concerns
7
7
  include AppExecutor, Registrable, Interruptible, Procline
8
8
 
9
+ attr_reader :name
10
+
11
+ def initialize(*)
12
+ @name = generate_name
13
+ end
14
+
9
15
  def kind
10
16
  self.class.name.demodulize
11
17
  end
@@ -21,6 +27,11 @@ module SolidQueue
21
27
  def metadata
22
28
  {}
23
29
  end
30
+
31
+ private
32
+ def generate_name
33
+ [ kind.downcase, SecureRandom.hex(10) ].join("-")
34
+ end
24
35
  end
25
36
  end
26
37
  end
@@ -8,6 +8,8 @@ module SolidQueue::Processes
8
8
 
9
9
  def initialize(polling_interval:, **options)
10
10
  @polling_interval = polling_interval
11
+
12
+ super(**options)
11
13
  end
12
14
 
13
15
  def metadata
@@ -21,6 +21,7 @@ module SolidQueue::Processes
21
21
  def register
22
22
  @process = SolidQueue::Process.register \
23
23
  kind: kind,
24
+ name: name,
24
25
  pid: pid,
25
26
  hostname: hostname,
26
27
  supervisor: try(:supervisor),
@@ -25,10 +25,6 @@ module SolidQueue::Processes
25
25
  @thread&.join
26
26
  end
27
27
 
28
- def name
29
- @name ||= [ kind.downcase, SecureRandom.hex(6) ].join("-")
30
- end
31
-
32
28
  def alive?
33
29
  !running_async? || @thread.alive?
34
30
  end
@@ -23,10 +23,13 @@ module SolidQueue
23
23
  attr_reader :threads
24
24
 
25
25
  def start_process(configured_process)
26
- configured_process.supervised_by process
27
- configured_process.start
26
+ process_instance = configured_process.instantiate.tap do |instance|
27
+ instance.supervised_by process
28
+ end
29
+
30
+ process_instance.start
28
31
 
29
- threads[configured_process.name] = configured_process
32
+ threads[process_instance.name] = process_instance
30
33
  end
31
34
 
32
35
  def stop_threads
@@ -1,12 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidQueue
4
+ class ProcessExitError < RuntimeError
5
+ def initialize(status)
6
+ message = case
7
+ when status.exitstatus.present? then "Process pid=#{status.pid} exited with status #{status.exitstatus}"
8
+ when status.signaled? then "Process pid=#{status.pid} received unhandled signal #{status.termsig}"
9
+ else "Process pid=#{status.pid} exited unexpectedly"
10
+ end
11
+
12
+ super(message)
13
+ end
14
+ end
15
+
4
16
  class Supervisor::ForkSupervisor < Supervisor
5
17
  include Signals, Pidfiled
6
18
 
7
19
  def initialize(*)
8
20
  super
21
+
9
22
  @forks = {}
23
+ @configured_processes = {}
10
24
  end
11
25
 
12
26
  def kind
@@ -14,7 +28,7 @@ module SolidQueue
14
28
  end
15
29
 
16
30
  private
17
- attr_reader :forks
31
+ attr_reader :forks, :configured_processes
18
32
 
19
33
  def supervise
20
34
  loop do
@@ -33,14 +47,17 @@ module SolidQueue
33
47
  end
34
48
 
35
49
  def start_process(configured_process)
36
- configured_process.supervised_by process
37
- configured_process.mode = :fork
50
+ process_instance = configured_process.instantiate.tap do |instance|
51
+ instance.supervised_by process
52
+ instance.mode = :fork
53
+ end
38
54
 
39
55
  pid = fork do
40
- configured_process.start
56
+ process_instance.start
41
57
  end
42
58
 
43
- forks[pid] = configured_process
59
+ configured_processes[pid] = configured_process
60
+ forks[pid] = process_instance
44
61
  end
45
62
 
46
63
  def terminate_gracefully
@@ -86,7 +103,11 @@ module SolidQueue
86
103
  pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
87
104
  break unless pid
88
105
 
89
- forks.delete(pid)
106
+ if (terminated_fork = forks.delete(pid)) && !status.exited? || status.exitstatus > 0
107
+ handle_claimed_jobs_by(terminated_fork, status)
108
+ end
109
+
110
+ configured_processes.delete(pid)
90
111
  end
91
112
  rescue SystemCallError
92
113
  # All children already reaped
@@ -94,13 +115,22 @@ module SolidQueue
94
115
 
95
116
  def replace_fork(pid, status)
96
117
  SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
97
- if supervised_fork = forks.delete(pid)
98
- payload[:fork] = supervised_fork
99
- start_process(supervised_fork)
118
+ if terminated_fork = forks.delete(pid)
119
+ payload[:fork] = terminated_fork
120
+ handle_claimed_jobs_by(terminated_fork, status)
121
+
122
+ start_process(configured_processes.delete(pid))
100
123
  end
101
124
  end
102
125
  end
103
126
 
127
+ def handle_claimed_jobs_by(terminated_fork, status)
128
+ if registered_process = process.supervisees.find_by(name: terminated_fork.name)
129
+ error = ProcessExitError.new(status)
130
+ registered_process.fail_all_claimed_executions_with(error)
131
+ end
132
+ end
133
+
104
134
  def all_forks_terminated?
105
135
  forks.empty?
106
136
  end
@@ -1,9 +1,15 @@
1
1
  module SolidQueue
2
+ class ProcessMissingError < RuntimeError
3
+ def initialize
4
+ super("The process that was running this job no longer exists")
5
+ end
6
+ end
7
+
2
8
  module Supervisor::Maintenance
3
9
  extend ActiveSupport::Concern
4
10
 
5
11
  included do
6
- after_boot :release_orphaned_executions
12
+ after_boot :fail_orphaned_executions
7
13
  end
8
14
 
9
15
  private
@@ -27,8 +33,10 @@ module SolidQueue
27
33
  wrap_in_app_executor { SolidQueue::Process.prune }
28
34
  end
29
35
 
30
- def release_orphaned_executions
31
- wrap_in_app_executor { SolidQueue::ClaimedExecution.orphaned.release_all }
36
+ def fail_orphaned_executions
37
+ wrap_in_app_executor do
38
+ SolidQueue::ClaimedExecution.orphaned.fail_all_with(ProcessMissingError.new)
39
+ end
32
40
  end
33
41
  end
34
42
  end
@@ -16,6 +16,7 @@ module SolidQueue
16
16
 
17
17
  def initialize(configuration)
18
18
  @configuration = configuration
19
+ super
19
20
  end
20
21
 
21
22
  def start
@@ -44,7 +45,7 @@ module SolidQueue
44
45
  end
45
46
 
46
47
  def start_processes
47
- configuration.processes.each { |configured_process| start_process(configured_process) }
48
+ configuration.configured_processes.each { |configured_process| start_process(configured_process) }
48
49
  end
49
50
 
50
51
  def stopped?
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-08-14 00:00:00.000000000 Z
11
+ date: 2024-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -220,6 +220,9 @@ files:
220
220
  - db/migrate/20240110143450_add_missing_index_to_blocked_executions.rb
221
221
  - db/migrate/20240218110712_create_recurring_executions.rb
222
222
  - db/migrate/20240719134516_create_recurring_tasks.rb
223
+ - db/migrate/20240811173327_add_name_to_processes.rb
224
+ - db/migrate/20240813160053_make_name_not_null.rb
225
+ - db/migrate/20240819165045_change_solid_queue_recurring_tasks_static_to_not_null.rb
223
226
  - lib/active_job/concurrency_controls.rb
224
227
  - lib/active_job/queue_adapters/solid_queue_adapter.rb
225
228
  - lib/generators/solid_queue/install/USAGE