pgbus 0.0.1 → 0.1.1
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 +4 -4
- data/README.md +37 -3
- data/Rakefile +98 -1
- data/app/controllers/pgbus/application_controller.rb +8 -0
- data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
- data/app/helpers/pgbus/application_helper.rb +41 -0
- data/app/models/pgbus/application_record.rb +7 -0
- data/app/models/pgbus/batch_entry.rb +31 -0
- data/app/models/pgbus/blocked_execution.rb +40 -0
- data/app/models/pgbus/process_entry.rb +9 -0
- data/app/models/pgbus/processed_event.rb +9 -0
- data/app/models/pgbus/recurring_execution.rb +33 -0
- data/app/models/pgbus/recurring_task.rb +42 -0
- data/app/models/pgbus/semaphore.rb +29 -0
- data/app/views/layouts/pgbus/application.html.erb +1 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
- data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
- data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
- data/config/routes.rb +7 -0
- data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
- data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
- data/lib/generators/pgbus/install_generator.rb +76 -2
- data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
- data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
- data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
- data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
- data/lib/pgbus/active_job/adapter.rb +0 -3
- data/lib/pgbus/active_job/executor.rb +27 -12
- data/lib/pgbus/batch.rb +60 -69
- data/lib/pgbus/cli.rb +11 -16
- data/lib/pgbus/client.rb +25 -7
- data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
- data/lib/pgbus/concurrency/semaphore.rb +11 -39
- data/lib/pgbus/concurrency.rb +10 -2
- data/lib/pgbus/configuration.rb +33 -0
- data/lib/pgbus/engine.rb +19 -1
- data/lib/pgbus/event_bus/handler.rb +4 -14
- data/lib/pgbus/instrumentation.rb +29 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
- data/lib/pgbus/pgmq_schema.rb +159 -0
- data/lib/pgbus/process/consumer.rb +8 -9
- data/lib/pgbus/process/dispatcher.rb +26 -24
- data/lib/pgbus/process/heartbeat.rb +15 -23
- data/lib/pgbus/process/signal_handler.rb +23 -1
- data/lib/pgbus/process/supervisor.rb +51 -2
- data/lib/pgbus/process/worker.rb +37 -9
- data/lib/pgbus/recurring/already_recorded.rb +7 -0
- data/lib/pgbus/recurring/command_job.rb +16 -0
- data/lib/pgbus/recurring/config_loader.rb +35 -0
- data/lib/pgbus/recurring/schedule.rb +102 -0
- data/lib/pgbus/recurring/scheduler.rb +102 -0
- data/lib/pgbus/recurring/task.rb +111 -0
- data/lib/pgbus/serializer.rb +10 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +187 -22
- data/lib/pgbus.rb +8 -0
- data/lib/tasks/pgbus_pgmq.rake +62 -0
- metadata +51 -24
- data/.bun-version +0 -1
- data/.claude/commands/architect.md +0 -100
- data/.claude/commands/github-review-comments.md +0 -237
- data/.claude/commands/lfg.md +0 -271
- data/.claude/commands/review-pr.md +0 -69
- data/.claude/commands/security.md +0 -122
- data/.claude/commands/tdd.md +0 -148
- data/.claude/rules/agents.md +0 -49
- data/.claude/rules/coding-style.md +0 -91
- data/.claude/rules/git-workflow.md +0 -56
- data/.claude/rules/performance.md +0 -73
- data/.claude/rules/testing.md +0 -67
- data/CLAUDE.md +0 -80
- data/CODE_OF_CONDUCT.md +0 -10
- data/bun.lock +0 -18
- data/docs/README.md +0 -28
- data/docs/switch_from_good_job.md +0 -279
- data/docs/switch_from_sidekiq.md +0 -226
- data/docs/switch_from_solid_queue.md +0 -247
- data/package.json +0 -9
- data/sig/pgbus.rbs +0 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Recurring
|
|
5
|
+
class Schedule
|
|
6
|
+
attr_reader :tasks
|
|
7
|
+
|
|
8
|
+
def initialize(config: Pgbus.configuration)
|
|
9
|
+
@config = config
|
|
10
|
+
@tasks = load_tasks
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def due_tasks(time = Time.now)
|
|
14
|
+
tasks.select { |task| task_due?(task, time) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def enqueue_task(task, run_at:)
|
|
18
|
+
queue = resolve_queue(task)
|
|
19
|
+
|
|
20
|
+
RecurringExecution.record(task.key, run_at) do
|
|
21
|
+
payload = build_payload(task)
|
|
22
|
+
headers = build_headers(task, run_at)
|
|
23
|
+
|
|
24
|
+
Pgbus.client.ensure_queue(queue)
|
|
25
|
+
Pgbus.client.send_message(queue, payload, headers: headers)
|
|
26
|
+
|
|
27
|
+
Pgbus.logger.info do
|
|
28
|
+
"[Pgbus] Enqueued recurring task #{task.key} (#{task.class_name || task.command}) " \
|
|
29
|
+
"for run_at=#{run_at.iso8601}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
rescue AlreadyRecorded
|
|
33
|
+
Pgbus.logger.debug { "[Pgbus] Recurring task #{task.key} already enqueued for #{run_at.iso8601}" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_payload(task)
|
|
37
|
+
if task.command
|
|
38
|
+
{
|
|
39
|
+
"job_class" => "Pgbus::Recurring::CommandJob",
|
|
40
|
+
"arguments" => [task.command],
|
|
41
|
+
"queue_name" => task.queue_name || @config.default_queue,
|
|
42
|
+
"priority" => nil
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
{
|
|
46
|
+
"job_class" => task.class_name,
|
|
47
|
+
"arguments" => task.arguments,
|
|
48
|
+
"queue_name" => task.queue_name || @config.default_queue,
|
|
49
|
+
"priority" => task.priority.zero? ? nil : task.priority
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_tasks
|
|
57
|
+
raw = @config.recurring_tasks || {}
|
|
58
|
+
raw.filter_map do |key, options|
|
|
59
|
+
options = options.transform_keys(&:to_s).transform_keys(&:to_sym) if options.is_a?(Hash)
|
|
60
|
+
task = Task.from_configuration(key, **(options || {}))
|
|
61
|
+
if task.valid?
|
|
62
|
+
task
|
|
63
|
+
else
|
|
64
|
+
Pgbus.logger.warn { "[Pgbus] Skipping invalid recurring task '#{key}': #{task.errors.join(", ")}" }
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def task_due?(task, time)
|
|
71
|
+
# A task is due when its most recent cron occurrence (previous_time)
|
|
72
|
+
# falls within the current tick window. We also check match? to
|
|
73
|
+
# handle the exact-boundary case where time == cron time.
|
|
74
|
+
cron = task.parsed_schedule
|
|
75
|
+
return false unless cron
|
|
76
|
+
|
|
77
|
+
# Check if `time` itself matches the cron (exact boundary hit)
|
|
78
|
+
return true if cron.match?(time)
|
|
79
|
+
|
|
80
|
+
# Check if the previous occurrence was recent enough that we should
|
|
81
|
+
# still fire it (handles the case where we tick slightly after the
|
|
82
|
+
# cron time). The window is the scheduler interval.
|
|
83
|
+
prev = task.previous_time(time)
|
|
84
|
+
return false unless prev
|
|
85
|
+
|
|
86
|
+
(time - prev) <= @config.recurring_schedule_interval
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_queue(task)
|
|
90
|
+
@config.queue_name(task.queue_name || @config.default_queue)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_headers(task, run_at)
|
|
94
|
+
{
|
|
95
|
+
"pgbus.recurring_key" => task.key,
|
|
96
|
+
"pgbus.recurring_run_at" => run_at.iso8601,
|
|
97
|
+
"pgbus.recurring_schedule" => task.schedule
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Recurring
|
|
5
|
+
class Scheduler
|
|
6
|
+
include Process::SignalHandler
|
|
7
|
+
|
|
8
|
+
attr_reader :schedule, :config
|
|
9
|
+
|
|
10
|
+
def initialize(config: Pgbus.configuration)
|
|
11
|
+
@config = config
|
|
12
|
+
@schedule = Schedule.new(config: config)
|
|
13
|
+
@shutting_down = false
|
|
14
|
+
@last_runs = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
setup_signals
|
|
19
|
+
start_heartbeat
|
|
20
|
+
|
|
21
|
+
Pgbus.logger.info do
|
|
22
|
+
"[Pgbus] Scheduler started: #{schedule.tasks.size} recurring tasks, " \
|
|
23
|
+
"interval=#{config.recurring_schedule_interval}s"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
loop do
|
|
27
|
+
break if @shutting_down
|
|
28
|
+
|
|
29
|
+
process_signals
|
|
30
|
+
break if @shutting_down
|
|
31
|
+
|
|
32
|
+
tick(Time.now)
|
|
33
|
+
break if @shutting_down
|
|
34
|
+
|
|
35
|
+
interruptible_sleep(config.recurring_schedule_interval)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
shutdown
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def tick(now)
|
|
42
|
+
schedule.due_tasks(now).each do |task|
|
|
43
|
+
run_at = task.previous_time(now)
|
|
44
|
+
next unless run_at
|
|
45
|
+
|
|
46
|
+
schedule.enqueue_task(task, run_at: run_at)
|
|
47
|
+
@last_runs[task.key] = now
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Pgbus.logger.error do
|
|
50
|
+
"[Pgbus] Error scheduling recurring task #{task.key}: #{e.class}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def last_run_at(key)
|
|
56
|
+
@last_runs[key]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def task_statuses
|
|
60
|
+
schedule.tasks.map do |task|
|
|
61
|
+
{
|
|
62
|
+
key: task.key,
|
|
63
|
+
class_name: task.class_name,
|
|
64
|
+
command: task.command,
|
|
65
|
+
schedule: task.schedule,
|
|
66
|
+
human_schedule: task.human_schedule,
|
|
67
|
+
queue_name: task.queue_name,
|
|
68
|
+
arguments: task.arguments,
|
|
69
|
+
priority: task.priority,
|
|
70
|
+
description: task.description,
|
|
71
|
+
next_run_at: task.next_time,
|
|
72
|
+
last_run_at: @last_runs[task.key]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def graceful_shutdown
|
|
78
|
+
@shutting_down = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def immediate_shutdown
|
|
82
|
+
@shutting_down = true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def start_heartbeat
|
|
88
|
+
@heartbeat = Process::Heartbeat.new(
|
|
89
|
+
kind: "scheduler",
|
|
90
|
+
metadata: { pid: ::Process.pid, tasks: schedule.tasks.size }
|
|
91
|
+
)
|
|
92
|
+
@heartbeat.start
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def shutdown
|
|
96
|
+
@heartbeat&.stop
|
|
97
|
+
restore_signals
|
|
98
|
+
Pgbus.logger.info { "[Pgbus] Scheduler stopped" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fugit"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Recurring
|
|
7
|
+
class Task
|
|
8
|
+
attr_reader :key, :class_name, :command, :schedule, :queue_name,
|
|
9
|
+
:arguments, :priority, :description
|
|
10
|
+
|
|
11
|
+
def self.from_configuration(key, **options)
|
|
12
|
+
options = options.transform_keys(&:to_sym)
|
|
13
|
+
new(
|
|
14
|
+
key: key,
|
|
15
|
+
class_name: options[:class],
|
|
16
|
+
command: options[:command],
|
|
17
|
+
schedule: options[:schedule],
|
|
18
|
+
queue_name: options[:queue],
|
|
19
|
+
arguments: Array(options[:args]),
|
|
20
|
+
priority: options.fetch(:priority, 0).to_i,
|
|
21
|
+
description: options[:description]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(key:, class_name: nil, command: nil, schedule: nil,
|
|
26
|
+
queue_name: nil, arguments: [], priority: 0, description: nil)
|
|
27
|
+
@key = key
|
|
28
|
+
@class_name = class_name
|
|
29
|
+
@command = command
|
|
30
|
+
@schedule = schedule
|
|
31
|
+
@queue_name = queue_name
|
|
32
|
+
@arguments = arguments || []
|
|
33
|
+
@priority = priority || 0
|
|
34
|
+
@description = description
|
|
35
|
+
@errors = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def valid?
|
|
39
|
+
@errors = []
|
|
40
|
+
validate!
|
|
41
|
+
@errors.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def errors
|
|
45
|
+
valid? unless defined?(@validated)
|
|
46
|
+
@errors
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parsed_schedule
|
|
50
|
+
@parsed_schedule ||= parse_schedule
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def next_time(from = Time.now)
|
|
54
|
+
parsed_schedule&.next_time(from)&.to_t
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def previous_time(from = Time.now)
|
|
58
|
+
parsed_schedule&.previous_time(from)&.to_t
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def human_schedule
|
|
62
|
+
return nil unless parsed_schedule
|
|
63
|
+
|
|
64
|
+
parsed_schedule.to_cron_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def job_class
|
|
68
|
+
class_name&.safe_constantize
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
key: key,
|
|
74
|
+
class_name: class_name,
|
|
75
|
+
command: command,
|
|
76
|
+
schedule: schedule,
|
|
77
|
+
queue_name: queue_name,
|
|
78
|
+
arguments: arguments,
|
|
79
|
+
priority: priority,
|
|
80
|
+
description: description
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate!
|
|
87
|
+
@validated = true
|
|
88
|
+
|
|
89
|
+
if schedule.nil? || schedule.to_s.strip.empty?
|
|
90
|
+
@errors << "Schedule is required"
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@errors << "Either class or command is required" if class_name.to_s.strip.empty? && command.to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
return if parsed_schedule.is_a?(Fugit::Cron)
|
|
97
|
+
|
|
98
|
+
@errors << "Schedule '#{schedule}' is not a valid cron expression"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_schedule
|
|
102
|
+
return nil if schedule.nil? || schedule.to_s.strip.empty?
|
|
103
|
+
|
|
104
|
+
parsed = Fugit.parse(schedule.to_s, multi: :fail)
|
|
105
|
+
parsed.is_a?(Fugit::Cron) ? parsed : nil
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/pgbus/serializer.rb
CHANGED
|
@@ -7,15 +7,19 @@ module Pgbus
|
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
9
|
def serialize_job(active_job)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Instrumentation.instrument("pgbus.serializer.serialize", kind: :job) do
|
|
11
|
+
data = active_job.serialize
|
|
12
|
+
# GlobalID is handled by ActiveJob's serialize — it converts AR objects
|
|
13
|
+
# to GlobalID URIs automatically. We just JSON-encode the result.
|
|
14
|
+
JSON.generate(data)
|
|
15
|
+
end
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def deserialize_job(json_string)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
Instrumentation.instrument("pgbus.serializer.deserialize", kind: :job) do
|
|
20
|
+
data = JSON.parse(json_string)
|
|
21
|
+
ActiveJob::Base.deserialize(data)
|
|
22
|
+
end
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def serialize_event(event)
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -23,24 +23,28 @@ module Pgbus
|
|
|
23
23
|
total_visible: total_visible,
|
|
24
24
|
active_processes: processes.count,
|
|
25
25
|
failed_count: failed_events_count,
|
|
26
|
-
dlq_depth: dlq_depth
|
|
26
|
+
dlq_depth: dlq_depth,
|
|
27
|
+
recurring_count: recurring_tasks_count
|
|
27
28
|
}
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
# Queues
|
|
31
|
+
# Queues — query via ActiveRecord for reliability in web processes
|
|
32
|
+
# (avoids PGMQ client connection issues when the web server uses a
|
|
33
|
+
# different connection lifecycle than the worker processes).
|
|
31
34
|
def queues_with_metrics
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
queue_names = connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
|
|
36
|
+
queue_names.map { |name| queue_metrics_via_sql(name) }.compact
|
|
34
37
|
rescue StandardError => e
|
|
35
|
-
Pgbus.logger.
|
|
38
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching queue metrics: #{e.class}: #{e.message}" }
|
|
36
39
|
[]
|
|
37
40
|
end
|
|
38
41
|
|
|
42
|
+
# name is the full PGMQ queue name (e.g. "pgbus_default") as returned
|
|
43
|
+
# by queues_with_metrics. No prefix is added.
|
|
39
44
|
def queue_detail(name)
|
|
40
|
-
|
|
41
|
-
m ? format_metrics(m) : nil
|
|
45
|
+
queue_metrics_via_sql(name)
|
|
42
46
|
rescue StandardError => e
|
|
43
|
-
Pgbus.logger.
|
|
47
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching queue detail for #{name}: #{e.class}: #{e.message}" }
|
|
44
48
|
nil
|
|
45
49
|
end
|
|
46
50
|
|
|
@@ -63,9 +67,8 @@ module Pgbus
|
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def job_detail(queue_name, msg_id)
|
|
66
|
-
full_name = Pgbus.configuration.queue_name(queue_name)
|
|
67
70
|
row = connection.select_one(
|
|
68
|
-
"SELECT * FROM pgmq.q_#{sanitize_name(
|
|
71
|
+
"SELECT * FROM pgmq.q_#{sanitize_name(queue_name)} WHERE msg_id = $1",
|
|
69
72
|
"Pgbus Job Detail",
|
|
70
73
|
[msg_id.to_i]
|
|
71
74
|
)
|
|
@@ -76,8 +79,7 @@ module Pgbus
|
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
def retry_job(queue_name, msg_id)
|
|
79
|
-
|
|
80
|
-
@client.set_visibility_timeout(full_name, msg_id.to_i, vt: 0)
|
|
82
|
+
@client.set_visibility_timeout(queue_name, msg_id.to_i, vt: 0)
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
def discard_job(queue_name, msg_id)
|
|
@@ -315,6 +317,127 @@ module Pgbus
|
|
|
315
317
|
false
|
|
316
318
|
end
|
|
317
319
|
|
|
320
|
+
# Recurring tasks
|
|
321
|
+
def recurring_tasks
|
|
322
|
+
records = RecurringTask.order(:key).to_a
|
|
323
|
+
last_runs = RecurringExecution
|
|
324
|
+
.where(task_key: records.map(&:key))
|
|
325
|
+
.select("task_key, MAX(run_at) AS run_at")
|
|
326
|
+
.group(:task_key)
|
|
327
|
+
.index_by(&:task_key)
|
|
328
|
+
|
|
329
|
+
records.map do |record|
|
|
330
|
+
last_exec = last_runs[record.key]
|
|
331
|
+
task = Recurring::Task.from_configuration(record.key,
|
|
332
|
+
class: record.class_name,
|
|
333
|
+
command: record.command,
|
|
334
|
+
schedule: record.schedule,
|
|
335
|
+
queue: record.queue_name,
|
|
336
|
+
args: parse_arguments(record.arguments),
|
|
337
|
+
priority: record.priority,
|
|
338
|
+
description: record.description)
|
|
339
|
+
|
|
340
|
+
{
|
|
341
|
+
id: record.id,
|
|
342
|
+
key: record.key,
|
|
343
|
+
class_name: record.class_name,
|
|
344
|
+
command: record.command,
|
|
345
|
+
schedule: record.schedule,
|
|
346
|
+
human_schedule: task.human_schedule,
|
|
347
|
+
queue_name: record.queue_name,
|
|
348
|
+
priority: record.priority,
|
|
349
|
+
description: record.description,
|
|
350
|
+
enabled: record.enabled,
|
|
351
|
+
static: record.static,
|
|
352
|
+
next_run_at: task.next_time,
|
|
353
|
+
last_run_at: last_exec&.run_at,
|
|
354
|
+
created_at: record.created_at,
|
|
355
|
+
updated_at: record.updated_at
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError => e
|
|
359
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching recurring tasks: #{e.class}: #{e.message}" }
|
|
360
|
+
[]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def recurring_task(id)
|
|
364
|
+
record = RecurringTask.find_by(id: id)
|
|
365
|
+
return nil unless record
|
|
366
|
+
|
|
367
|
+
task = Recurring::Task.from_configuration(record.key,
|
|
368
|
+
class: record.class_name,
|
|
369
|
+
command: record.command,
|
|
370
|
+
schedule: record.schedule,
|
|
371
|
+
queue: record.queue_name,
|
|
372
|
+
args: parse_arguments(record.arguments),
|
|
373
|
+
priority: record.priority,
|
|
374
|
+
description: record.description)
|
|
375
|
+
|
|
376
|
+
executions = RecurringExecution.for_task(record.key).recent(25).map do |exec|
|
|
377
|
+
{ run_at: exec.run_at, created_at: exec.created_at }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
{
|
|
381
|
+
id: record.id,
|
|
382
|
+
key: record.key,
|
|
383
|
+
class_name: record.class_name,
|
|
384
|
+
command: record.command,
|
|
385
|
+
schedule: record.schedule,
|
|
386
|
+
human_schedule: task.human_schedule,
|
|
387
|
+
queue_name: record.queue_name,
|
|
388
|
+
arguments: parse_arguments(record.arguments),
|
|
389
|
+
priority: record.priority,
|
|
390
|
+
description: record.description,
|
|
391
|
+
enabled: record.enabled,
|
|
392
|
+
static: record.static,
|
|
393
|
+
next_run_at: task.next_time,
|
|
394
|
+
executions: executions,
|
|
395
|
+
created_at: record.created_at,
|
|
396
|
+
updated_at: record.updated_at
|
|
397
|
+
}
|
|
398
|
+
rescue StandardError => e
|
|
399
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching recurring task #{id}: #{e.class}: #{e.message}" }
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def toggle_recurring_task(id)
|
|
404
|
+
record = RecurringTask.find_by(id: id)
|
|
405
|
+
return false unless record
|
|
406
|
+
|
|
407
|
+
record.update!(enabled: !record.enabled)
|
|
408
|
+
true
|
|
409
|
+
rescue StandardError => e
|
|
410
|
+
Pgbus.logger.error { "[Pgbus::Web] Error toggling recurring task #{id}: #{e.message}" }
|
|
411
|
+
false
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def enqueue_recurring_task_now(id)
|
|
415
|
+
record = RecurringTask.find_by(id: id)
|
|
416
|
+
return false unless record
|
|
417
|
+
|
|
418
|
+
task = Recurring::Task.from_configuration(record.key,
|
|
419
|
+
class: record.class_name,
|
|
420
|
+
command: record.command,
|
|
421
|
+
schedule: record.schedule,
|
|
422
|
+
queue: record.queue_name,
|
|
423
|
+
args: parse_arguments(record.arguments),
|
|
424
|
+
priority: record.priority)
|
|
425
|
+
|
|
426
|
+
schedule = Recurring::Schedule.new(config: Pgbus.configuration)
|
|
427
|
+
schedule.enqueue_task(task, run_at: Time.now.utc)
|
|
428
|
+
true
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
Pgbus.logger.error { "[Pgbus::Web] Error enqueuing recurring task #{id}: #{e.message}" }
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def recurring_tasks_count
|
|
435
|
+
RecurringTask.count
|
|
436
|
+
rescue StandardError => e
|
|
437
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error counting recurring tasks: #{e.message}" }
|
|
438
|
+
0
|
|
439
|
+
end
|
|
440
|
+
|
|
318
441
|
# Subscriber registry
|
|
319
442
|
def registered_subscribers
|
|
320
443
|
EventBus::Registry.instance.subscribers.map do |s|
|
|
@@ -328,12 +451,12 @@ module Pgbus
|
|
|
328
451
|
private
|
|
329
452
|
|
|
330
453
|
def connection
|
|
331
|
-
|
|
454
|
+
Pgbus::ApplicationRecord.connection
|
|
332
455
|
end
|
|
333
456
|
|
|
457
|
+
# name is the full PGMQ queue name (already prefixed)
|
|
334
458
|
def query_queue_messages(name, limit, offset)
|
|
335
|
-
|
|
336
|
-
query_queue_messages_raw(full_name, limit, offset).map { |m| m.merge(queue: name) }
|
|
459
|
+
query_queue_messages_raw(name, limit, offset).map { |m| m.merge(queue: name) }
|
|
337
460
|
end
|
|
338
461
|
|
|
339
462
|
def query_queue_messages_raw(full_name, limit, offset)
|
|
@@ -357,15 +480,45 @@ module Pgbus
|
|
|
357
480
|
messages.sort_by { |m| -m[:msg_id].to_i }.slice(offset, limit) || []
|
|
358
481
|
end
|
|
359
482
|
|
|
360
|
-
def
|
|
483
|
+
def queue_metrics_via_sql(queue_name)
|
|
484
|
+
qtable = "q_#{sanitize_name(queue_name)}"
|
|
485
|
+
seq_name = "#{qtable}_msg_id_seq"
|
|
486
|
+
|
|
487
|
+
row = connection.select_one(<<~SQL, "Pgbus Queue Metrics")
|
|
488
|
+
WITH q_summary AS (
|
|
489
|
+
SELECT
|
|
490
|
+
count(*) AS queue_length,
|
|
491
|
+
count(CASE WHEN vt <= NOW() THEN 1 END) AS queue_visible_length,
|
|
492
|
+
EXTRACT(epoch FROM (NOW() - max(enqueued_at)))::int AS newest_msg_age_sec,
|
|
493
|
+
EXTRACT(epoch FROM (NOW() - min(enqueued_at)))::int AS oldest_msg_age_sec
|
|
494
|
+
FROM pgmq.#{qtable}
|
|
495
|
+
),
|
|
496
|
+
all_metrics AS (
|
|
497
|
+
SELECT CASE WHEN is_called THEN last_value ELSE 0 END AS total_messages
|
|
498
|
+
FROM pgmq.#{seq_name}
|
|
499
|
+
)
|
|
500
|
+
SELECT
|
|
501
|
+
q_summary.queue_length,
|
|
502
|
+
q_summary.queue_visible_length,
|
|
503
|
+
q_summary.newest_msg_age_sec,
|
|
504
|
+
q_summary.oldest_msg_age_sec,
|
|
505
|
+
all_metrics.total_messages
|
|
506
|
+
FROM q_summary, all_metrics
|
|
507
|
+
SQL
|
|
508
|
+
|
|
509
|
+
return nil unless row
|
|
510
|
+
|
|
361
511
|
{
|
|
362
|
-
name:
|
|
363
|
-
queue_length:
|
|
364
|
-
queue_visible_length:
|
|
365
|
-
oldest_msg_age_sec:
|
|
366
|
-
newest_msg_age_sec:
|
|
367
|
-
total_messages:
|
|
512
|
+
name: queue_name,
|
|
513
|
+
queue_length: row["queue_length"].to_i,
|
|
514
|
+
queue_visible_length: row["queue_visible_length"].to_i,
|
|
515
|
+
oldest_msg_age_sec: row["oldest_msg_age_sec"]&.to_i,
|
|
516
|
+
newest_msg_age_sec: row["newest_msg_age_sec"]&.to_i,
|
|
517
|
+
total_messages: row["total_messages"].to_i
|
|
368
518
|
}
|
|
519
|
+
rescue StandardError => e
|
|
520
|
+
Pgbus.logger.error { "[Pgbus::Web] Error fetching metrics for #{queue_name}: #{e.class}: #{e.message}" }
|
|
521
|
+
nil
|
|
369
522
|
end
|
|
370
523
|
|
|
371
524
|
def format_message(row, queue_name)
|
|
@@ -401,6 +554,18 @@ module Pgbus
|
|
|
401
554
|
def sanitize_name(name)
|
|
402
555
|
name.gsub(/[^a-zA-Z0-9_]/, "")
|
|
403
556
|
end
|
|
557
|
+
|
|
558
|
+
def parse_arguments(args)
|
|
559
|
+
case args
|
|
560
|
+
when Array then args
|
|
561
|
+
when String then JSON.parse(args)
|
|
562
|
+
when NilClass then []
|
|
563
|
+
else Array(args)
|
|
564
|
+
end
|
|
565
|
+
rescue JSON::ParserError => e
|
|
566
|
+
Pgbus.logger.debug { "[Pgbus::Web] Invalid recurring task arguments JSON: #{e.message}" }
|
|
567
|
+
[]
|
|
568
|
+
end
|
|
404
569
|
end
|
|
405
570
|
end
|
|
406
571
|
end
|
data/lib/pgbus.rb
CHANGED
|
@@ -16,6 +16,13 @@ module Pgbus
|
|
|
16
16
|
loader = Zeitwerk::Loader.for_gem
|
|
17
17
|
loader.inflector.inflect("pgbus" => "Pgbus", "cli" => "CLI", "dsl" => "DSL")
|
|
18
18
|
loader.ignore("#{__dir__}/generators")
|
|
19
|
+
loader.ignore("#{__dir__}/active_job")
|
|
20
|
+
# Register app/models for non-Rails usage (specs, standalone).
|
|
21
|
+
# When Rails is running, the Engine handles autoloading app/models.
|
|
22
|
+
unless defined?(Rails::Engine)
|
|
23
|
+
models_dir = File.expand_path("../app/models", __dir__)
|
|
24
|
+
loader.push_dir(models_dir) if File.directory?(models_dir)
|
|
25
|
+
end
|
|
19
26
|
loader
|
|
20
27
|
end
|
|
21
28
|
end
|
|
@@ -46,4 +53,5 @@ module Pgbus
|
|
|
46
53
|
loader.setup
|
|
47
54
|
end
|
|
48
55
|
|
|
56
|
+
require "active_job/queue_adapters/pgbus_adapter" if defined?(ActiveJob)
|
|
49
57
|
require "pgbus/engine" if defined?(Rails::Engine)
|