pgbus 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -3
  3. data/Rakefile +98 -1
  4. data/app/controllers/pgbus/application_controller.rb +8 -0
  5. data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
  6. data/app/helpers/pgbus/application_helper.rb +41 -0
  7. data/app/models/pgbus/application_record.rb +7 -0
  8. data/app/models/pgbus/batch_entry.rb +31 -0
  9. data/app/models/pgbus/blocked_execution.rb +40 -0
  10. data/app/models/pgbus/process_entry.rb +9 -0
  11. data/app/models/pgbus/processed_event.rb +9 -0
  12. data/app/models/pgbus/recurring_execution.rb +33 -0
  13. data/app/models/pgbus/recurring_task.rb +42 -0
  14. data/app/models/pgbus/semaphore.rb +29 -0
  15. data/app/views/layouts/pgbus/application.html.erb +1 -0
  16. data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
  17. data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
  18. data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
  19. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
  20. data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
  21. data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
  22. data/config/routes.rb +7 -0
  23. data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
  24. data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
  25. data/lib/generators/pgbus/install_generator.rb +76 -2
  26. data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
  27. data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
  28. data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
  29. data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
  30. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
  31. data/lib/pgbus/active_job/adapter.rb +3 -6
  32. data/lib/pgbus/active_job/executor.rb +26 -12
  33. data/lib/pgbus/batch.rb +65 -72
  34. data/lib/pgbus/cli.rb +11 -16
  35. data/lib/pgbus/client.rb +32 -15
  36. data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
  37. data/lib/pgbus/concurrency/semaphore.rb +11 -39
  38. data/lib/pgbus/concurrency.rb +10 -2
  39. data/lib/pgbus/configuration.rb +48 -0
  40. data/lib/pgbus/engine.rb +19 -1
  41. data/lib/pgbus/event_bus/handler.rb +10 -23
  42. data/lib/pgbus/instrumentation.rb +29 -0
  43. data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
  44. data/lib/pgbus/pgmq_schema.rb +159 -0
  45. data/lib/pgbus/process/consumer.rb +17 -9
  46. data/lib/pgbus/process/dispatcher.rb +33 -41
  47. data/lib/pgbus/process/heartbeat.rb +15 -23
  48. data/lib/pgbus/process/signal_handler.rb +23 -1
  49. data/lib/pgbus/process/supervisor.rb +79 -2
  50. data/lib/pgbus/process/worker.rb +42 -13
  51. data/lib/pgbus/recurring/already_recorded.rb +7 -0
  52. data/lib/pgbus/recurring/command_job.rb +28 -0
  53. data/lib/pgbus/recurring/config_loader.rb +35 -0
  54. data/lib/pgbus/recurring/schedule.rb +102 -0
  55. data/lib/pgbus/recurring/scheduler.rb +102 -0
  56. data/lib/pgbus/recurring/task.rb +111 -0
  57. data/lib/pgbus/serializer.rb +16 -6
  58. data/lib/pgbus/version.rb +1 -1
  59. data/lib/pgbus/web/data_source.rb +217 -36
  60. data/lib/pgbus.rb +8 -0
  61. data/lib/tasks/pgbus_pgmq.rake +62 -0
  62. metadata +51 -24
  63. data/.bun-version +0 -1
  64. data/.claude/commands/architect.md +0 -100
  65. data/.claude/commands/github-review-comments.md +0 -237
  66. data/.claude/commands/lfg.md +0 -271
  67. data/.claude/commands/review-pr.md +0 -69
  68. data/.claude/commands/security.md +0 -122
  69. data/.claude/commands/tdd.md +0 -148
  70. data/.claude/rules/agents.md +0 -49
  71. data/.claude/rules/coding-style.md +0 -91
  72. data/.claude/rules/git-workflow.md +0 -56
  73. data/.claude/rules/performance.md +0 -73
  74. data/.claude/rules/testing.md +0 -67
  75. data/CLAUDE.md +0 -80
  76. data/CODE_OF_CONDUCT.md +0 -10
  77. data/bun.lock +0 -18
  78. data/docs/README.md +0 -28
  79. data/docs/switch_from_good_job.md +0 -279
  80. data/docs/switch_from_sidekiq.md +0 -226
  81. data/docs/switch_from_solid_queue.md +0 -247
  82. data/package.json +0 -9
  83. data/sig/pgbus.rbs +0 -4
@@ -28,14 +28,16 @@ module Pgbus
28
28
  def run
29
29
  setup_signals
30
30
  start_heartbeat
31
+ resolve_wildcard_queues
31
32
  Pgbus.logger.info { "[Pgbus] Worker started: queues=#{queues.join(",")} threads=#{threads} pid=#{::Process.pid}" }
32
33
 
33
34
  loop do
34
- break if @shutting_down
35
- break if recycle_needed?
36
-
37
35
  process_signals
38
- claim_and_execute
36
+ break if @shutting_down && @pool.queue_length.zero?
37
+ break if recycle_needed? && @pool.queue_length.zero?
38
+
39
+ claim_and_execute unless @shutting_down || recycle_needed?
40
+ interruptible_sleep(config.polling_interval) if (@shutting_down || recycle_needed?) && !@pool.queue_length.zero?
39
41
  end
40
42
 
41
43
  shutdown
@@ -56,29 +58,31 @@ module Pgbus
56
58
 
57
59
  def claim_and_execute
58
60
  idle = @pool.max_length - @pool.queue_length
59
- return sleep(config.polling_interval) if idle <= 0
61
+ return interruptible_sleep(config.polling_interval) if idle <= 0
60
62
 
61
- messages = fetch_messages(idle)
63
+ tagged_messages = fetch_messages(idle)
62
64
 
63
- if messages.empty?
64
- sleep(config.polling_interval)
65
+ if tagged_messages.empty?
66
+ interruptible_sleep(config.polling_interval)
65
67
  return
66
68
  end
67
69
 
68
- messages.each do |message|
69
- queue_name = message.respond_to?(:queue_name) ? message.queue_name : queues.first
70
+ tagged_messages.each do |queue_name, message|
70
71
  @pool.post { process_message(message, queue_name) }
71
72
  end
72
73
  end
73
74
 
75
+ # Returns an array of [queue_name, message] pairs so we always know
76
+ # which queue each message came from (PGMQ messages don't carry this).
74
77
  def fetch_messages(qty)
75
78
  if queues.size == 1
76
- Pgbus.client.read_batch(queues.first, qty: qty) || []
79
+ queue = queues.first
80
+ messages = Pgbus.client.read_batch(queue, qty: qty) || []
81
+ messages.map { |m| [queue, m] }
77
82
  else
78
- # Multi-queue read: read from each queue proportionally
79
83
  per_queue = [(qty / queues.size.to_f).ceil, 1].max
80
84
  queues.flat_map do |q|
81
- Pgbus.client.read_batch(q, qty: per_queue) || []
85
+ (Pgbus.client.read_batch(q, qty: per_queue) || []).map { |m| [q, m] }
82
86
  end.first(qty)
83
87
  end
84
88
  rescue StandardError => e
@@ -95,6 +99,31 @@ module Pgbus
95
99
  Pgbus.logger.error { "[Pgbus] Unhandled error processing message: #{e.message}" }
96
100
  end
97
101
 
102
+ # Resolve "*" to all non-DLQ queues from pgmq.meta, stripping the prefix.
103
+ # Called once at startup. If no wildcard, this is a no-op.
104
+ def resolve_wildcard_queues
105
+ return unless @queues.include?("*")
106
+
107
+ dlq_suffix = config.dead_letter_queue_suffix
108
+ prefix = "#{config.queue_prefix}_"
109
+
110
+ all_queues = ActiveRecord::Base.connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
111
+ resolved = all_queues
112
+ .reject { |q| q.end_with?(dlq_suffix) }
113
+ .map { |q| q.delete_prefix(prefix) }
114
+
115
+ if resolved.empty?
116
+ Pgbus.logger.warn { "[Pgbus] Wildcard queue '*' resolved to no queues — falling back to default" }
117
+ @queues = [config.default_queue]
118
+ else
119
+ @queues = resolved
120
+ Pgbus.logger.info { "[Pgbus] Wildcard queue '*' resolved to: #{@queues.join(", ")}" }
121
+ end
122
+ rescue StandardError => e
123
+ Pgbus.logger.error { "[Pgbus] Failed to resolve wildcard queues: #{e.message} — falling back to default" }
124
+ @queues = [config.default_queue]
125
+ end
126
+
98
127
  def recycle_needed?
99
128
  exceeded_max_jobs? || exceeded_max_memory? || exceeded_max_lifetime?
100
129
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Recurring
5
+ class AlreadyRecorded < Pgbus::Error; end
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Recurring
5
+ # Job class for command-based recurring tasks.
6
+ # Evaluates a method call chain on a constant, e.g. "OldRecord.cleanup!".
7
+ #
8
+ # Only supports `ConstantName.method_name` and `ConstantName.method_name(args)`.
9
+ # Rejects arbitrary Ruby expressions for safety.
10
+ class CommandJob < ::ActiveJob::Base
11
+ SAFE_COMMAND = /\A([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)\.([a-z_][a-z0-9_!?]*)\z/
12
+
13
+ def perform(command)
14
+ match = SAFE_COMMAND.match(command)
15
+ unless match
16
+ raise ArgumentError,
17
+ "Unsafe recurring command: #{command.inspect}. " \
18
+ "Must be in the form 'ClassName.method_name'."
19
+ end
20
+
21
+ klass = match[1].safe_constantize
22
+ raise ArgumentError, "Unknown class in recurring command: #{match[1]}" unless klass
23
+
24
+ klass.public_send(match[2])
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+
6
+ module Pgbus
7
+ module Recurring
8
+ module ConfigLoader
9
+ module_function
10
+
11
+ def load(path, env: nil)
12
+ return {} unless path && File.exist?(path.to_s)
13
+
14
+ env ||= detect_env
15
+ raw = File.read(path)
16
+ parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
17
+ return {} unless parsed.is_a?(Hash)
18
+
19
+ # If the parsed hash has an environment key, use that subtree
20
+ parsed.key?(env) ? parsed.fetch(env, {}) : parsed
21
+ rescue StandardError => e
22
+ Pgbus.logger.error { "[Pgbus] Failed to load recurring config from #{path}: #{e.message}" }
23
+ {}
24
+ end
25
+
26
+ def detect_env
27
+ if defined?(Rails)
28
+ Rails.env.to_s
29
+ else
30
+ ENV.fetch("PGBUS_ENV", "development")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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
@@ -7,15 +7,25 @@ module Pgbus
7
7
  module_function
8
8
 
9
9
  def serialize_job(active_job)
10
- data = active_job.serialize
11
- # GlobalID is handled by ActiveJob's serialize — it converts AR objects
12
- # to GlobalID URIs automatically. We just JSON-encode the result.
13
- JSON.generate(data)
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
16
+ end
17
+
18
+ def serialize_job_hash(active_job)
19
+ Instrumentation.instrument("pgbus.serializer.serialize", kind: :job) do
20
+ active_job.serialize
21
+ end
14
22
  end
15
23
 
16
24
  def deserialize_job(json_string)
17
- data = JSON.parse(json_string)
18
- ActiveJob::Base.deserialize(data)
25
+ Instrumentation.instrument("pgbus.serializer.deserialize", kind: :job) do
26
+ data = JSON.parse(json_string)
27
+ ActiveJob::Base.deserialize(data)
28
+ end
19
29
  end
20
30
 
21
31
  def serialize_event(event)
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.2"
5
5
  end