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.
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 +0 -3
  32. data/lib/pgbus/active_job/executor.rb +27 -12
  33. data/lib/pgbus/batch.rb +60 -69
  34. data/lib/pgbus/cli.rb +11 -16
  35. data/lib/pgbus/client.rb +25 -7
  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 +33 -0
  40. data/lib/pgbus/engine.rb +19 -1
  41. data/lib/pgbus/event_bus/handler.rb +4 -14
  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 +8 -9
  46. data/lib/pgbus/process/dispatcher.rb +26 -24
  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 +51 -2
  50. data/lib/pgbus/process/worker.rb +37 -9
  51. data/lib/pgbus/recurring/already_recorded.rb +7 -0
  52. data/lib/pgbus/recurring/command_job.rb +16 -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 +10 -6
  58. data/lib/pgbus/version.rb +1 -1
  59. data/lib/pgbus/web/data_source.rb +187 -22
  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
@@ -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,19 @@ 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
14
16
  end
15
17
 
16
18
  def deserialize_job(json_string)
17
- data = JSON.parse(json_string)
18
- ActiveJob::Base.deserialize(data)
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
@@ -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.1"
5
5
  end
@@ -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
- metrics = @client.metrics || []
33
- Array(metrics).map { |m| format_metrics(m) }
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.debug { "[Pgbus::Web] Error fetching queue metrics: #{e.message}" }
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
- m = @client.metrics(name)
41
- m ? format_metrics(m) : nil
45
+ queue_metrics_via_sql(name)
42
46
  rescue StandardError => e
43
- Pgbus.logger.debug { "[Pgbus::Web] Error fetching queue detail for #{name}: #{e.message}" }
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(full_name)} WHERE msg_id = $1",
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
- full_name = Pgbus.configuration.queue_name(queue_name)
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
- ActiveRecord::Base.connection
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
- full_name = Pgbus.configuration.queue_name(name)
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 format_metrics(m)
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: m.queue_name.to_s,
363
- queue_length: m.queue_length.to_i,
364
- queue_visible_length: m.queue_visible_length.to_i,
365
- oldest_msg_age_sec: m.oldest_msg_age_sec&.to_i,
366
- newest_msg_age_sec: m.newest_msg_age_sec&.to_i,
367
- total_messages: m.total_messages.to_i
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)