solid_queue 0.4.1 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/jobs/solid_queue/recurring_job.rb +9 -0
- data/app/models/solid_queue/claimed_execution.rb +19 -7
- data/app/models/solid_queue/process/executor.rb +6 -0
- data/app/models/solid_queue/process/prunable.rb +14 -1
- data/app/models/solid_queue/process.rb +3 -5
- data/app/models/solid_queue/recurring_execution.rb +17 -4
- data/app/models/solid_queue/recurring_task/arguments.rb +17 -0
- data/app/models/solid_queue/recurring_task.rb +122 -0
- data/app/models/solid_queue/semaphore.rb +18 -5
- data/db/migrate/20240719134516_create_recurring_tasks.rb +20 -0
- data/db/migrate/20240811173327_add_name_to_processes.rb +5 -0
- data/db/migrate/20240813160053_make_name_not_null.rb +16 -0
- data/db/migrate/20240819165045_change_solid_queue_recurring_tasks_static_to_not_null.rb +5 -0
- data/lib/solid_queue/configuration.rb +26 -20
- data/lib/solid_queue/dispatcher/recurring_schedule.rb +21 -12
- data/lib/solid_queue/dispatcher.rb +7 -7
- data/lib/solid_queue/log_subscriber.rb +13 -6
- data/lib/solid_queue/processes/base.rb +11 -0
- data/lib/solid_queue/processes/poller.rb +2 -0
- data/lib/solid_queue/processes/registrable.rb +1 -0
- data/lib/solid_queue/processes/runnable.rb +0 -4
- data/lib/solid_queue/supervisor/async_supervisor.rb +6 -3
- data/lib/solid_queue/supervisor/fork_supervisor.rb +39 -9
- data/lib/solid_queue/supervisor/maintenance.rb +11 -3
- data/lib/solid_queue/supervisor.rb +2 -1
- data/lib/solid_queue/version.rb +1 -1
- metadata +9 -3
- data/lib/solid_queue/dispatcher/recurring_task.rb +0 -99
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e62ec09bbed1f2bcb96da96865c3516adbc254ad7cea234d3b6573ea001c758e
|
4
|
+
data.tar.gz: d78e7543194f3f6470bdb80c52ffca78c520a602d93423185c02ba82475bbda6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
@@ -7,16 +7,29 @@ module SolidQueue
|
|
7
7
|
scope :clearable, -> { where.missing(:job) }
|
8
8
|
|
9
9
|
class << self
|
10
|
+
def create_or_insert!(**attributes)
|
11
|
+
if connection.supports_insert_conflict_target?
|
12
|
+
# PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
|
13
|
+
# during two concurrent INSERTs for the same value of an unique index. We need to explicitly
|
14
|
+
# indicate unique_by to ignore duplicate rows by this value when inserting
|
15
|
+
unless insert(attributes, unique_by: [ :task_key, :run_at ]).any?
|
16
|
+
raise AlreadyRecorded
|
17
|
+
end
|
18
|
+
else
|
19
|
+
create!(**attributes)
|
20
|
+
end
|
21
|
+
rescue ActiveRecord::RecordNotUnique
|
22
|
+
raise AlreadyRecorded
|
23
|
+
end
|
24
|
+
|
10
25
|
def record(task_key, run_at, &block)
|
11
26
|
transaction do
|
12
27
|
block.call.tap do |active_job|
|
13
|
-
if active_job
|
14
|
-
|
28
|
+
if active_job && active_job.successfully_enqueued?
|
29
|
+
create_or_insert!(job_id: active_job.provider_job_id, task_key: task_key, run_at: run_at)
|
15
30
|
end
|
16
31
|
end
|
17
32
|
end
|
18
|
-
rescue ActiveRecord::RecordNotUnique => e
|
19
|
-
raise AlreadyRecorded
|
20
33
|
end
|
21
34
|
|
22
35
|
def clear_in_batches(batch_size: 500)
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_job/arguments"
|
4
|
+
|
5
|
+
module SolidQueue
|
6
|
+
class RecurringTask::Arguments
|
7
|
+
class << self
|
8
|
+
def load(data)
|
9
|
+
data.nil? ? [] : ActiveJob::Arguments.deserialize(ActiveSupport::JSON.load(data))
|
10
|
+
end
|
11
|
+
|
12
|
+
def dump(data)
|
13
|
+
ActiveSupport::JSON.dump(ActiveJob::Arguments.serialize(Array(data)))
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fugit"
|
4
|
+
|
5
|
+
module SolidQueue
|
6
|
+
class RecurringTask < Record
|
7
|
+
serialize :arguments, coder: Arguments, default: []
|
8
|
+
|
9
|
+
validate :supported_schedule
|
10
|
+
validate :existing_job_class
|
11
|
+
|
12
|
+
scope :static, -> { where(static: true) }
|
13
|
+
|
14
|
+
class << self
|
15
|
+
def wrap(args)
|
16
|
+
args.is_a?(self) ? args : from_configuration(args.first, **args.second)
|
17
|
+
end
|
18
|
+
|
19
|
+
def from_configuration(key, **options)
|
20
|
+
new(key: key, class_name: options[:class], schedule: options[:schedule], arguments: options[:args])
|
21
|
+
end
|
22
|
+
|
23
|
+
def create_or_update_all(tasks)
|
24
|
+
if connection.supports_insert_conflict_target?
|
25
|
+
# PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict
|
26
|
+
# during two concurrent INSERTs for the same value of an unique index. We need to explicitly
|
27
|
+
# indicate unique_by to ignore duplicate rows by this value when inserting
|
28
|
+
upsert_all tasks.map(&:attributes_for_upsert), unique_by: :key
|
29
|
+
else
|
30
|
+
upsert_all tasks.map(&:attributes_for_upsert)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def delay_from_now
|
36
|
+
[ (next_time - Time.current).to_f, 0 ].max
|
37
|
+
end
|
38
|
+
|
39
|
+
def next_time
|
40
|
+
parsed_schedule.next_time.utc
|
41
|
+
end
|
42
|
+
|
43
|
+
def enqueue(at:)
|
44
|
+
SolidQueue.instrument(:enqueue_recurring_task, task: key, at: at) do |payload|
|
45
|
+
active_job = if using_solid_queue_adapter?
|
46
|
+
enqueue_and_record(run_at: at)
|
47
|
+
else
|
48
|
+
payload[:other_adapter] = true
|
49
|
+
|
50
|
+
perform_later do |job|
|
51
|
+
unless job.successfully_enqueued?
|
52
|
+
payload[:enqueue_error] = job.enqueue_error&.message
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
payload[:active_job_id] = active_job.job_id if active_job
|
58
|
+
rescue RecurringExecution::AlreadyRecorded
|
59
|
+
payload[:skipped] = true
|
60
|
+
rescue Job::EnqueueError => error
|
61
|
+
payload[:enqueue_error] = error.message
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
"#{class_name}.perform_later(#{arguments.map(&:inspect).join(",")}) [ #{parsed_schedule.original} ]"
|
67
|
+
end
|
68
|
+
|
69
|
+
def attributes_for_upsert
|
70
|
+
attributes.without("id", "created_at", "updated_at")
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
def supported_schedule
|
75
|
+
unless parsed_schedule.instance_of?(Fugit::Cron)
|
76
|
+
errors.add :schedule, :unsupported, message: "is not a supported recurring schedule"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def existing_job_class
|
81
|
+
unless job_class.present?
|
82
|
+
errors.add :class_name, :undefined, message: "doesn't correspond to an existing class"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def using_solid_queue_adapter?
|
87
|
+
job_class.queue_adapter_name.inquiry.solid_queue?
|
88
|
+
end
|
89
|
+
|
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
|
99
|
+
end
|
100
|
+
|
101
|
+
def perform_later(&block)
|
102
|
+
job_class.perform_later(*arguments_with_kwargs, &block)
|
103
|
+
end
|
104
|
+
|
105
|
+
def arguments_with_kwargs
|
106
|
+
if arguments.last.is_a?(Hash)
|
107
|
+
arguments[0...-1] + [ Hash.ruby2_keywords_hash(arguments.last) ]
|
108
|
+
else
|
109
|
+
arguments
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
def parsed_schedule
|
115
|
+
@parsed_schedule ||= Fugit.parse(schedule)
|
116
|
+
end
|
117
|
+
|
118
|
+
def job_class
|
119
|
+
@job_class ||= class_name&.safe_constantize
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -17,6 +17,17 @@ module SolidQueue
|
|
17
17
|
def signal_all(jobs)
|
18
18
|
Proxy.signal_all(jobs)
|
19
19
|
end
|
20
|
+
|
21
|
+
# Requires a unique index on key
|
22
|
+
def create_unique_by(attributes)
|
23
|
+
if connection.supports_insert_conflict_target?
|
24
|
+
insert({ **attributes }, unique_by: :key).any?
|
25
|
+
else
|
26
|
+
create!(**attributes)
|
27
|
+
end
|
28
|
+
rescue ActiveRecord::RecordNotUnique
|
29
|
+
false
|
30
|
+
end
|
20
31
|
end
|
21
32
|
|
22
33
|
class Proxy
|
@@ -44,15 +55,17 @@ module SolidQueue
|
|
44
55
|
attr_accessor :job
|
45
56
|
|
46
57
|
def attempt_creation
|
47
|
-
Semaphore.
|
48
|
-
|
49
|
-
rescue ActiveRecord::RecordNotUnique
|
50
|
-
if limit == 1 then false
|
58
|
+
if Semaphore.create_unique_by(key: key, value: limit - 1, expires_at: expires_at)
|
59
|
+
true
|
51
60
|
else
|
52
|
-
|
61
|
+
check_limit_or_decrement
|
53
62
|
end
|
54
63
|
end
|
55
64
|
|
65
|
+
def check_limit_or_decrement
|
66
|
+
limit == 1 ? false : attempt_decrement
|
67
|
+
end
|
68
|
+
|
56
69
|
def attempt_decrement
|
57
70
|
Semaphore.available.where(key: key).update_all([ "value = value - 1, expires_at = ?", expires_at ]) > 0
|
58
71
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateRecurringTasks < ActiveRecord::Migration[7.1]
|
2
|
+
def change
|
3
|
+
create_table :solid_queue_recurring_tasks do |t|
|
4
|
+
t.string :key, null: false, index: { unique: true }
|
5
|
+
t.string :schedule, null: false
|
6
|
+
t.string :command, limit: 2048
|
7
|
+
t.string :class_name
|
8
|
+
t.text :arguments
|
9
|
+
|
10
|
+
t.string :queue_name
|
11
|
+
t.integer :priority, default: 0
|
12
|
+
|
13
|
+
t.boolean :static, default: true, index: true
|
14
|
+
|
15
|
+
t.text :description
|
16
|
+
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
end
|
20
|
+
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
|
@@ -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
|
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
|
@@ -75,7 +81,7 @@ module SolidQueue
|
|
75
81
|
|
76
82
|
def parse_recurring_tasks(tasks)
|
77
83
|
Array(tasks).map do |id, options|
|
78
|
-
|
84
|
+
RecurringTask.from_configuration(id, **options)
|
79
85
|
end.select(&:valid?)
|
80
86
|
end
|
81
87
|
|
@@ -7,7 +7,7 @@ module SolidQueue
|
|
7
7
|
attr_reader :configured_tasks, :scheduled_tasks
|
8
8
|
|
9
9
|
def initialize(tasks)
|
10
|
-
@configured_tasks = Array(tasks).map { |task|
|
10
|
+
@configured_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?)
|
11
11
|
@scheduled_tasks = Concurrent::Hash.new
|
12
12
|
end
|
13
13
|
|
@@ -15,33 +15,42 @@ module SolidQueue
|
|
15
15
|
configured_tasks.empty?
|
16
16
|
end
|
17
17
|
|
18
|
-
def
|
18
|
+
def schedule_tasks
|
19
|
+
wrap_in_app_executor do
|
20
|
+
persist_tasks
|
21
|
+
reload_tasks
|
22
|
+
end
|
23
|
+
|
19
24
|
configured_tasks.each do |task|
|
20
|
-
|
25
|
+
schedule_task(task)
|
21
26
|
end
|
22
27
|
end
|
23
28
|
|
24
|
-
def
|
29
|
+
def schedule_task(task)
|
25
30
|
scheduled_tasks[task.key] = schedule(task)
|
26
31
|
end
|
27
32
|
|
28
|
-
def
|
33
|
+
def unschedule_tasks
|
29
34
|
scheduled_tasks.values.each(&:cancel)
|
30
35
|
scheduled_tasks.clear
|
31
36
|
end
|
32
37
|
|
33
|
-
def
|
34
|
-
configured_tasks.
|
35
|
-
end
|
36
|
-
|
37
|
-
def inspect
|
38
|
-
configured_tasks.map(&:to_s).join(" | ")
|
38
|
+
def task_keys
|
39
|
+
configured_tasks.map(&:key)
|
39
40
|
end
|
40
41
|
|
41
42
|
private
|
43
|
+
def persist_tasks
|
44
|
+
SolidQueue::RecurringTask.create_or_update_all configured_tasks
|
45
|
+
end
|
46
|
+
|
47
|
+
def reload_tasks
|
48
|
+
@configured_tasks = SolidQueue::RecurringTask.where(key: task_keys)
|
49
|
+
end
|
50
|
+
|
42
51
|
def schedule(task)
|
43
52
|
scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
|
44
|
-
thread_schedule.
|
53
|
+
thread_schedule.schedule_task(thread_task)
|
45
54
|
|
46
55
|
wrap_in_app_executor do
|
47
56
|
thread_task.enqueue(at: thread_task_run_at)
|
@@ -4,8 +4,8 @@ module SolidQueue
|
|
4
4
|
class Dispatcher < Processes::Poller
|
5
5
|
attr_accessor :batch_size, :concurrency_maintenance, :recurring_schedule
|
6
6
|
|
7
|
-
after_boot :start_concurrency_maintenance, :
|
8
|
-
before_shutdown :stop_concurrency_maintenance, :
|
7
|
+
after_boot :start_concurrency_maintenance, :schedule_recurring_tasks
|
8
|
+
before_shutdown :stop_concurrency_maintenance, :unschedule_recurring_tasks
|
9
9
|
|
10
10
|
def initialize(**options)
|
11
11
|
options = options.dup.with_defaults(SolidQueue::Configuration::DISPATCHER_DEFAULTS)
|
@@ -19,7 +19,7 @@ module SolidQueue
|
|
19
19
|
end
|
20
20
|
|
21
21
|
def metadata
|
22
|
-
super.merge(batch_size: batch_size, concurrency_maintenance_interval: concurrency_maintenance&.interval, recurring_schedule: recurring_schedule.
|
22
|
+
super.merge(batch_size: batch_size, concurrency_maintenance_interval: concurrency_maintenance&.interval, recurring_schedule: recurring_schedule.task_keys.presence)
|
23
23
|
end
|
24
24
|
|
25
25
|
private
|
@@ -38,16 +38,16 @@ module SolidQueue
|
|
38
38
|
concurrency_maintenance&.start
|
39
39
|
end
|
40
40
|
|
41
|
-
def
|
42
|
-
recurring_schedule.
|
41
|
+
def schedule_recurring_tasks
|
42
|
+
recurring_schedule.schedule_tasks
|
43
43
|
end
|
44
44
|
|
45
45
|
def stop_concurrency_maintenance
|
46
46
|
concurrency_maintenance&.stop
|
47
47
|
end
|
48
48
|
|
49
|
-
def
|
50
|
-
recurring_schedule.
|
49
|
+
def unschedule_recurring_tasks
|
50
|
+
recurring_schedule.unschedule_tasks
|
51
51
|
end
|
52
52
|
|
53
53
|
def all_work_completed?
|
@@ -12,11 +12,15 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def release_many_claimed(event)
|
15
|
-
|
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
|
-
|
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
|
@@ -23,10 +23,13 @@ module SolidQueue
|
|
23
23
|
attr_reader :threads
|
24
24
|
|
25
25
|
def start_process(configured_process)
|
26
|
-
configured_process.
|
27
|
-
|
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[
|
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.
|
37
|
-
|
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
|
-
|
56
|
+
process_instance.start
|
41
57
|
end
|
42
58
|
|
43
|
-
|
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
|
98
|
-
payload[:fork] =
|
99
|
-
|
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 :
|
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
|
31
|
-
wrap_in_app_executor
|
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.
|
48
|
+
configuration.configured_processes.each { |configured_process| start_process(configured_process) }
|
48
49
|
end
|
49
50
|
|
50
51
|
def stopped?
|
data/lib/solid_queue/version.rb
CHANGED
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.
|
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-
|
11
|
+
date: 2024-08-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -188,6 +188,7 @@ files:
|
|
188
188
|
- MIT-LICENSE
|
189
189
|
- README.md
|
190
190
|
- Rakefile
|
191
|
+
- app/jobs/solid_queue/recurring_job.rb
|
191
192
|
- app/models/solid_queue/blocked_execution.rb
|
192
193
|
- app/models/solid_queue/claimed_execution.rb
|
193
194
|
- app/models/solid_queue/execution.rb
|
@@ -210,12 +211,18 @@ files:
|
|
210
211
|
- app/models/solid_queue/ready_execution.rb
|
211
212
|
- app/models/solid_queue/record.rb
|
212
213
|
- app/models/solid_queue/recurring_execution.rb
|
214
|
+
- app/models/solid_queue/recurring_task.rb
|
215
|
+
- app/models/solid_queue/recurring_task/arguments.rb
|
213
216
|
- app/models/solid_queue/scheduled_execution.rb
|
214
217
|
- app/models/solid_queue/semaphore.rb
|
215
218
|
- config/routes.rb
|
216
219
|
- db/migrate/20231211200639_create_solid_queue_tables.rb
|
217
220
|
- db/migrate/20240110143450_add_missing_index_to_blocked_executions.rb
|
218
221
|
- db/migrate/20240218110712_create_recurring_executions.rb
|
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
|
219
226
|
- lib/active_job/concurrency_controls.rb
|
220
227
|
- lib/active_job/queue_adapters/solid_queue_adapter.rb
|
221
228
|
- lib/generators/solid_queue/install/USAGE
|
@@ -228,7 +235,6 @@ files:
|
|
228
235
|
- lib/solid_queue/dispatcher.rb
|
229
236
|
- lib/solid_queue/dispatcher/concurrency_maintenance.rb
|
230
237
|
- lib/solid_queue/dispatcher/recurring_schedule.rb
|
231
|
-
- lib/solid_queue/dispatcher/recurring_task.rb
|
232
238
|
- lib/solid_queue/engine.rb
|
233
239
|
- lib/solid_queue/log_subscriber.rb
|
234
240
|
- lib/solid_queue/pool.rb
|
@@ -1,99 +0,0 @@
|
|
1
|
-
require "fugit"
|
2
|
-
|
3
|
-
module SolidQueue
|
4
|
-
class Dispatcher::RecurringTask
|
5
|
-
class << self
|
6
|
-
def wrap(args)
|
7
|
-
args.is_a?(self) ? args : from_configuration(args.first, **args.second)
|
8
|
-
end
|
9
|
-
|
10
|
-
def from_configuration(key, **options)
|
11
|
-
new(key, class_name: options[:class], schedule: options[:schedule], arguments: options[:args])
|
12
|
-
end
|
13
|
-
end
|
14
|
-
|
15
|
-
attr_reader :key, :schedule, :class_name, :arguments
|
16
|
-
|
17
|
-
def initialize(key, class_name:, schedule:, arguments: nil)
|
18
|
-
@key = key
|
19
|
-
@class_name = class_name
|
20
|
-
@schedule = schedule
|
21
|
-
@arguments = Array(arguments)
|
22
|
-
end
|
23
|
-
|
24
|
-
def delay_from_now
|
25
|
-
[ (next_time - Time.current).to_f, 0 ].max
|
26
|
-
end
|
27
|
-
|
28
|
-
def next_time
|
29
|
-
parsed_schedule.next_time.utc
|
30
|
-
end
|
31
|
-
|
32
|
-
def enqueue(at:)
|
33
|
-
SolidQueue.instrument(:enqueue_recurring_task, task: key, at: at) do |payload|
|
34
|
-
active_job = if using_solid_queue_adapter?
|
35
|
-
perform_later_and_record(run_at: at)
|
36
|
-
else
|
37
|
-
payload[:other_adapter] = true
|
38
|
-
|
39
|
-
perform_later do |job|
|
40
|
-
unless job.successfully_enqueued?
|
41
|
-
payload[:enqueue_error] = job.enqueue_error&.message
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
payload[:active_job_id] = active_job.job_id if active_job
|
47
|
-
rescue RecurringExecution::AlreadyRecorded
|
48
|
-
payload[:skipped] = true
|
49
|
-
rescue Job::EnqueueError => error
|
50
|
-
payload[:enqueue_error] = error.message
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
def valid?
|
55
|
-
parsed_schedule.instance_of?(Fugit::Cron)
|
56
|
-
end
|
57
|
-
|
58
|
-
def to_s
|
59
|
-
"#{class_name}.perform_later(#{arguments.map(&:inspect).join(",")}) [ #{parsed_schedule.original} ]"
|
60
|
-
end
|
61
|
-
|
62
|
-
def to_h
|
63
|
-
{
|
64
|
-
schedule: schedule,
|
65
|
-
class_name: class_name,
|
66
|
-
arguments: arguments
|
67
|
-
}
|
68
|
-
end
|
69
|
-
|
70
|
-
private
|
71
|
-
def using_solid_queue_adapter?
|
72
|
-
job_class.queue_adapter_name.inquiry.solid_queue?
|
73
|
-
end
|
74
|
-
|
75
|
-
def perform_later_and_record(run_at:)
|
76
|
-
RecurringExecution.record(key, run_at) { perform_later }
|
77
|
-
end
|
78
|
-
|
79
|
-
def perform_later(&block)
|
80
|
-
job_class.perform_later(*arguments_with_kwargs, &block)
|
81
|
-
end
|
82
|
-
|
83
|
-
def arguments_with_kwargs
|
84
|
-
if arguments.last.is_a?(Hash)
|
85
|
-
arguments[0...-1] + [ Hash.ruby2_keywords_hash(arguments.last) ]
|
86
|
-
else
|
87
|
-
arguments
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
def parsed_schedule
|
92
|
-
@parsed_schedule ||= Fugit.parse(schedule)
|
93
|
-
end
|
94
|
-
|
95
|
-
def job_class
|
96
|
-
@job_class ||= class_name.safe_constantize
|
97
|
-
end
|
98
|
-
end
|
99
|
-
end
|