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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Manages embedded PGMQ SQL schema for extension-free installations.
5
+ #
6
+ # Supports three modes:
7
+ # :auto - Try extension first, fall back to embedded SQL
8
+ # :extension - Require the pgmq PostgreSQL extension
9
+ # :embedded - Use vendored SQL (no extension needed)
10
+ #
11
+ # Vendored SQL files live in lib/pgbus/pgmq_schema/pgmq_v{VERSION}.sql
12
+ # and are exact copies of the upstream pgmq-extension/sql/pgmq.sql at each release.
13
+ module PgmqSchema
14
+ class VersionNotFoundError < StandardError; end
15
+
16
+ SCHEMA_DIR = File.expand_path("pgmq_schema", __dir__).freeze
17
+
18
+ class << self
19
+ # Returns the latest vendored PGMQ version string.
20
+ def latest_version
21
+ available_versions.last
22
+ end
23
+
24
+ # Returns sorted list of all vendored PGMQ versions.
25
+ def available_versions
26
+ Dir.glob(File.join(SCHEMA_DIR, "pgmq_v*.sql"))
27
+ .map { |f| File.basename(f).match(/pgmq_v(.+)\.sql/)[1] }
28
+ .sort_by { |v| Gem::Version.new(v) }
29
+ end
30
+
31
+ # Returns the filesystem path to the vendored SQL file for a given version.
32
+ #
33
+ # @param version [String] e.g. "1.11.0"
34
+ # @return [String] absolute path
35
+ # @raise [VersionNotFoundError] if no SQL file exists for that version
36
+ def sql_path(version)
37
+ path = File.join(SCHEMA_DIR, "pgmq_v#{version}.sql")
38
+ raise VersionNotFoundError, "No vendored PGMQ SQL for version #{version}" unless File.exist?(path)
39
+
40
+ path
41
+ end
42
+
43
+ # Returns the raw SQL content for a given version.
44
+ def sql_for_version(version)
45
+ File.read(sql_path(version))
46
+ end
47
+
48
+ # Returns the SQL to install PGMQ schema without the extension.
49
+ # Strips the extension-only pg_dump config blocks since they're
50
+ # irrelevant when not installed as an extension.
51
+ #
52
+ # @param version [String] defaults to latest
53
+ # @return [String] SQL
54
+ def install_sql(version = latest_version)
55
+ sql = sql_for_version(version)
56
+ strip_extension_only_blocks(sql)
57
+ end
58
+
59
+ # Returns SQL to create the version tracking table and record an installation.
60
+ #
61
+ # @param version [String] defaults to latest
62
+ # @return [String] SQL
63
+ def version_tracking_sql(version = latest_version)
64
+ <<~SQL
65
+ CREATE TABLE IF NOT EXISTS pgbus_pgmq_schema_versions (
66
+ id SERIAL PRIMARY KEY,
67
+ version VARCHAR NOT NULL,
68
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
69
+ install_method VARCHAR NOT NULL DEFAULT 'embedded'
70
+ );
71
+
72
+ INSERT INTO pgbus_pgmq_schema_versions (version, install_method)
73
+ VALUES ('#{version}', 'embedded');
74
+ SQL
75
+ end
76
+
77
+ # Returns SQL to record an extension-based installation in the version tracking table.
78
+ #
79
+ # @param version [String]
80
+ # @return [String] SQL
81
+ def version_tracking_extension_sql(version = latest_version)
82
+ <<~SQL
83
+ CREATE TABLE IF NOT EXISTS pgbus_pgmq_schema_versions (
84
+ id SERIAL PRIMARY KEY,
85
+ version VARCHAR NOT NULL,
86
+ installed_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
87
+ install_method VARCHAR NOT NULL DEFAULT 'embedded'
88
+ );
89
+
90
+ INSERT INTO pgbus_pgmq_schema_versions (version, install_method)
91
+ VALUES ('#{version}', 'extension');
92
+ SQL
93
+ end
94
+
95
+ # SQL to drop all pgmq functions/types (for clean upgrade).
96
+ # Uses CASCADE so dependent objects are also dropped.
97
+ def drop_pgmq_functions_sql
98
+ <<~SQL
99
+ DO $$
100
+ DECLARE
101
+ r RECORD;
102
+ BEGIN
103
+ -- Drop all functions in pgmq schema
104
+ FOR r IN
105
+ SELECT pg_catalog.pg_get_functiondef(p.oid) AS funcdef,
106
+ n.nspname || '.' || p.proname || '(' ||
107
+ pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS func_sig
108
+ FROM pg_catalog.pg_proc p
109
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
110
+ WHERE n.nspname = 'pgmq'
111
+ LOOP
112
+ EXECUTE 'DROP FUNCTION IF EXISTS ' || r.func_sig || ' CASCADE';
113
+ END LOOP;
114
+
115
+ -- Drop custom types in pgmq schema
116
+ FOR r IN
117
+ SELECT n.nspname || '.' || t.typname AS type_name
118
+ FROM pg_catalog.pg_type t
119
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
120
+ WHERE n.nspname = 'pgmq'
121
+ AND t.typtype = 'c'
122
+ AND NOT EXISTS (
123
+ SELECT 1 FROM pg_catalog.pg_class c
124
+ WHERE c.reltype = t.oid
125
+ )
126
+ LOOP
127
+ EXECUTE 'DROP TYPE IF EXISTS ' || r.type_name || ' CASCADE';
128
+ END LOOP;
129
+ END $$;
130
+ SQL
131
+ end
132
+
133
+ private
134
+
135
+ # Strips extension-specific blocks (pg_extension_config_dump, pg_depend checks)
136
+ # that only work when pgmq is installed as an extension.
137
+ def strip_extension_only_blocks(sql)
138
+ # Remove the DO block that conditionally creates schema only when extension is missing.
139
+ # Replace with unconditional schema creation.
140
+ sql = sql.sub(
141
+ /DO\s*\$\$\s*BEGIN\s*IF\s*\(SELECT\s+NOT\s+EXISTS.*?END\s*\$\$;/m,
142
+ "CREATE SCHEMA IF NOT EXISTS pgmq;"
143
+ )
144
+
145
+ # Remove pg_extension_config_dump blocks
146
+ sql = sql.gsub(
147
+ /DO\s*\$\$\s*BEGIN\s*IF\s+EXISTS\(SELECT\s+1\s+FROM\s+pg_extension.*?END\s*\$\$;/m,
148
+ ""
149
+ )
150
+
151
+ # Remove _belongs_to_pgmq function (checks pg_depend on extension)
152
+ sql.gsub(
153
+ /CREATE FUNCTION pgmq\._belongs_to_pgmq.*?LANGUAGE plpgsql;/m,
154
+ ""
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
@@ -54,23 +54,23 @@ module Pgbus
54
54
 
55
55
  def consume
56
56
  idle = @pool.max_length - @pool.queue_length
57
- return sleep(config.polling_interval) if idle <= 0
57
+ return interruptible_sleep(config.polling_interval) if idle <= 0
58
58
 
59
- messages = @queue_names.flat_map do |queue_name|
60
- Pgbus.client.read_batch(queue_name, qty: idle) || []
59
+ tagged_messages = @queue_names.flat_map do |queue_name|
60
+ (Pgbus.client.read_batch(queue_name, qty: idle) || []).map { |m| [queue_name, m] }
61
61
  end.first(idle)
62
62
 
63
- if messages.empty?
64
- sleep(config.polling_interval)
63
+ if tagged_messages.empty?
64
+ interruptible_sleep(config.polling_interval)
65
65
  return
66
66
  end
67
67
 
68
- messages.each do |message|
69
- @pool.post { handle_message(message) }
68
+ tagged_messages.each do |queue_name, message|
69
+ @pool.post { handle_message(message, queue_name) }
70
70
  end
71
71
  end
72
72
 
73
- def handle_message(message)
73
+ def handle_message(message, queue_name)
74
74
  raw = JSON.parse(message.message)
75
75
  routing_key = raw.dig("headers", "routing_key") || raw["routing_key"]
76
76
 
@@ -80,7 +80,6 @@ module Pgbus
80
80
  handler.process(message)
81
81
  end
82
82
 
83
- queue_name = message.respond_to?(:queue_name) ? message.queue_name : @queue_names.first
84
83
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
85
84
  rescue StandardError => e
86
85
  Pgbus.logger.error { "[Pgbus] Consumer error: #{e.class}: #{e.message}" }
@@ -6,10 +6,11 @@ module Pgbus
6
6
  include SignalHandler
7
7
 
8
8
  # Maintenance runs on coarser intervals than the main loop
9
- CLEANUP_INTERVAL = 3600 # Run idempotency cleanup every hour
10
- REAP_INTERVAL = 300 # Run stale process reaping every 5 minutes
11
- CONCURRENCY_INTERVAL = 300 # Run concurrency cleanup every 5 minutes
12
- BATCH_CLEANUP_INTERVAL = 3600 # Run batch cleanup every hour
9
+ CLEANUP_INTERVAL = 3600 # Run idempotency cleanup every hour
10
+ REAP_INTERVAL = 300 # Run stale process reaping every 5 minutes
11
+ CONCURRENCY_INTERVAL = 300 # Run concurrency cleanup every 5 minutes
12
+ BATCH_CLEANUP_INTERVAL = 3600 # Run batch cleanup every hour
13
+ RECURRING_CLEANUP_INTERVAL = 3600 # Run recurring execution cleanup every hour
13
14
 
14
15
  attr_reader :config
15
16
 
@@ -20,6 +21,7 @@ module Pgbus
20
21
  @last_reap_at = Time.now
21
22
  @last_concurrency_at = Time.now
22
23
  @last_batch_cleanup_at = Time.now
24
+ @last_recurring_cleanup_at = Time.now
23
25
  end
24
26
 
25
27
  def run
@@ -38,7 +40,7 @@ module Pgbus
38
40
  run_maintenance
39
41
  break if @shutting_down
40
42
 
41
- sleep(config.dispatch_interval)
43
+ interruptible_sleep(config.dispatch_interval)
42
44
  end
43
45
 
44
46
  shutdown
@@ -76,35 +78,28 @@ module Pgbus
76
78
  cleanup_batches
77
79
  @last_batch_cleanup_at = now
78
80
  end
81
+
82
+ if now - @last_recurring_cleanup_at >= RECURRING_CLEANUP_INTERVAL
83
+ cleanup_recurring_executions
84
+ @last_recurring_cleanup_at = now
85
+ end
79
86
  rescue StandardError => e
80
87
  Pgbus.logger.error { "[Pgbus] Dispatcher maintenance error: #{e.message}" }
81
88
  end
82
89
 
83
90
  def cleanup_processed_events
84
- return unless defined?(ActiveRecord::Base)
85
-
86
91
  ttl = config.idempotency_ttl
87
92
  return unless ttl&.positive?
88
93
 
89
- deleted = ActiveRecord::Base.connection.delete(
90
- "DELETE FROM pgbus_processed_events WHERE processed_at < $1",
91
- "Pgbus Idempotency Cleanup",
92
- [Time.now.utc - ttl]
93
- )
94
+ deleted = ProcessedEvent.expired(Time.now.utc - ttl).delete_all
94
95
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
95
96
  rescue StandardError => e
96
97
  Pgbus.logger.warn { "[Pgbus] Idempotency cleanup failed: #{e.message}" }
97
98
  end
98
99
 
99
100
  def reap_stale_processes
100
- return unless defined?(ActiveRecord::Base)
101
-
102
101
  threshold = Heartbeat::ALIVE_THRESHOLD
103
- deleted = ActiveRecord::Base.connection.delete(
104
- "DELETE FROM pgbus_processes WHERE last_heartbeat_at < $1",
105
- "Pgbus Stale Process Reap",
106
- [Time.now.utc - threshold]
107
- )
102
+ deleted = ProcessEntry.stale(Time.now.utc - threshold).delete_all
108
103
  Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
109
104
  rescue StandardError => e
110
105
  Pgbus.logger.warn { "[Pgbus] Stale process reaping failed: #{e.message}" }
@@ -123,11 +118,8 @@ module Pgbus
123
118
  end
124
119
 
125
120
  def release_blocked_for_key(key)
126
- released = Concurrency::BlockedExecution.release_next(key)
127
- return unless released
128
-
129
- Pgbus.client.send_message(released[:queue_name], released[:payload])
130
- Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" }
121
+ promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
122
+ Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
131
123
  rescue StandardError => e
132
124
  Pgbus.logger.warn { "[Pgbus] Failed to release blocked execution for #{key}: #{e.message}" }
133
125
  end
@@ -139,6 +131,16 @@ module Pgbus
139
131
  Pgbus.logger.warn { "[Pgbus] Batch cleanup failed: #{e.message}" }
140
132
  end
141
133
 
134
+ def cleanup_recurring_executions
135
+ retention = config.recurring_execution_retention
136
+ return unless retention&.positive?
137
+
138
+ deleted = RecurringExecution.older_than(Time.now.utc - retention).delete_all
139
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
140
+ rescue StandardError => e
141
+ Pgbus.logger.warn { "[Pgbus] Recurring execution cleanup failed: #{e.message}" }
142
+ end
143
+
142
144
  def start_heartbeat
143
145
  @heartbeat = Heartbeat.new(kind: "dispatcher", metadata: { pid: ::Process.pid })
144
146
  @heartbeat.start
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "concurrent"
4
+ require "socket"
4
5
 
5
6
  module Pgbus
6
7
  module Process
@@ -8,7 +9,7 @@ module Pgbus
8
9
  INTERVAL = 60 # seconds
9
10
  ALIVE_THRESHOLD = 300 # 5 minutes
10
11
 
11
- attr_reader :process_record
12
+ attr_reader :process_entry
12
13
 
13
14
  def initialize(kind:, metadata: {})
14
15
  @kind = kind
@@ -28,13 +29,9 @@ module Pgbus
28
29
  end
29
30
 
30
31
  def beat
31
- return unless @process_id && defined?(ActiveRecord::Base)
32
+ return unless @process_id
32
33
 
33
- ActiveRecord::Base.connection.execute(
34
- "UPDATE pgbus_processes SET last_heartbeat_at = NOW() WHERE id = $1",
35
- "Pgbus Heartbeat",
36
- [@process_id]
37
- )
34
+ ProcessEntry.where(id: @process_id).update_all(last_heartbeat_at: Time.current)
38
35
  rescue StandardError => e
39
36
  Pgbus.logger.warn { "[Pgbus] Heartbeat failed: #{e.message}" }
40
37
  end
@@ -42,29 +39,24 @@ module Pgbus
42
39
  private
43
40
 
44
41
  def register_process
45
- return unless defined?(ActiveRecord::Base)
46
-
47
- result = ActiveRecord::Base.connection.exec_insert(
48
- "INSERT INTO pgbus_processes (kind, hostname, pid, metadata, last_heartbeat_at, created_at, updated_at) " \
49
- "VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) RETURNING id",
50
- "Pgbus Register Process",
51
- [@kind, Socket.gethostname, ::Process.pid, JSON.generate(@metadata)]
42
+ record = ProcessEntry.create!(
43
+ kind: @kind,
44
+ hostname: Socket.gethostname,
45
+ pid: ::Process.pid,
46
+ metadata: @metadata,
47
+ last_heartbeat_at: Time.current
52
48
  )
53
- @process_id = result.first["id"]
49
+ @process_id = record.id
54
50
  rescue StandardError => e
55
51
  Pgbus.logger.warn { "[Pgbus] Process registration failed: #{e.message}" }
56
52
  end
57
53
 
58
54
  def deregister_process
59
- return unless @process_id && defined?(ActiveRecord::Base)
55
+ return unless @process_id
60
56
 
61
- ActiveRecord::Base.connection.execute(
62
- "DELETE FROM pgbus_processes WHERE id = $1",
63
- "Pgbus Deregister Process",
64
- [@process_id]
65
- )
66
- rescue StandardError
67
- # Best effort — process is exiting anyway
57
+ ProcessEntry.where(id: @process_id).delete_all
58
+ rescue StandardError => e
59
+ Pgbus.logger.warn { "[Pgbus] Process deregistration failed: #{e.message}" }
68
60
  end
69
61
  end
70
62
  end
@@ -10,9 +10,14 @@ module Pgbus
10
10
  def setup_signals
11
11
  @signal_queue = Queue.new
12
12
  @previous_handlers = {}
13
+ @self_pipe_r, @self_pipe_w = IO.pipe
13
14
 
14
15
  %w[INT TERM QUIT].each do |sig|
15
- @previous_handlers[sig] = trap(sig) { @signal_queue << sig }
16
+ @previous_handlers[sig] = trap(sig) do
17
+ @signal_queue << sig
18
+ # Wake any IO.select / interruptible_sleep call
19
+ @self_pipe_w.write_nonblock(".", exception: false)
20
+ end
16
21
  end
17
22
  end
18
23
 
@@ -20,6 +25,8 @@ module Pgbus
20
25
  @previous_handlers&.each do |sig, handler|
21
26
  trap(sig, handler || "DEFAULT")
22
27
  end
28
+ @self_pipe_r&.close unless @self_pipe_r&.closed?
29
+ @self_pipe_w&.close unless @self_pipe_w&.closed?
23
30
  end
24
31
 
25
32
  def process_signals
@@ -37,6 +44,13 @@ module Pgbus
37
44
  end
38
45
  end
39
46
 
47
+ # Sleep that can be interrupted by signals. Use this instead of Kernel#sleep
48
+ # in any loop that needs to respond to INT/TERM/QUIT promptly.
49
+ def interruptible_sleep(seconds)
50
+ @self_pipe_r.wait_readable(seconds)
51
+ drain_self_pipe
52
+ end
53
+
40
54
  def graceful_shutdown
41
55
  raise NotImplementedError
42
56
  end
@@ -44,6 +58,14 @@ module Pgbus
44
58
  def immediate_shutdown
45
59
  raise NotImplementedError
46
60
  end
61
+
62
+ private
63
+
64
+ def drain_self_pipe
65
+ loop { @self_pipe_r.read_nonblock(256) }
66
+ rescue IO::WaitReadable, EOFError
67
+ # pipe drained
68
+ end
47
69
  end
48
70
  end
49
71
  end
@@ -50,6 +50,9 @@ module Pgbus
50
50
  # Boot dispatcher
51
51
  fork_dispatcher
52
52
 
53
+ # Boot recurring scheduler if configured
54
+ boot_scheduler
55
+
53
56
  # Boot event consumers if configured
54
57
  boot_consumers
55
58
  end
@@ -83,6 +86,50 @@ module Pgbus
83
86
  Pgbus.logger.info { "[Pgbus] Forked dispatcher pid=#{pid}" }
84
87
  end
85
88
 
89
+ def boot_scheduler
90
+ return if config.skip_recurring
91
+ return unless recurring_tasks_configured?
92
+
93
+ fork_scheduler
94
+ end
95
+
96
+ def fork_scheduler
97
+ pid = fork do
98
+ restore_signals
99
+ setup_child_signals
100
+ load_rails_app
101
+ load_recurring_config
102
+ scheduler = Recurring::Scheduler.new(config: config)
103
+ scheduler.run
104
+ end
105
+
106
+ @forks[pid] = { type: :scheduler }
107
+ Pgbus.logger.info { "[Pgbus] Forked scheduler pid=#{pid}" }
108
+ end
109
+
110
+ def recurring_tasks_configured?
111
+ return true if config.recurring_tasks&.any?
112
+ return true if config.recurring_tasks_file && File.exist?(config.recurring_tasks_file.to_s)
113
+
114
+ # Check default location
115
+ if defined?(Rails)
116
+ default_path = Rails.root.join("config", "recurring.yml")
117
+ return File.exist?(default_path.to_s)
118
+ end
119
+
120
+ false
121
+ end
122
+
123
+ def load_recurring_config
124
+ return if config.recurring_tasks&.any?
125
+
126
+ path = config.recurring_tasks_file
127
+ path ||= defined?(Rails) ? Rails.root.join("config", "recurring.yml") : nil
128
+ return unless path && File.exist?(path.to_s)
129
+
130
+ config.recurring_tasks = Recurring::ConfigLoader.load(path)
131
+ end
132
+
86
133
  def boot_consumers
87
134
  return unless config.event_consumers
88
135
 
@@ -113,7 +160,7 @@ module Pgbus
113
160
 
114
161
  process_signals
115
162
  reap_children
116
- sleep(FORK_WAIT)
163
+ interruptible_sleep(FORK_WAIT)
117
164
  end
118
165
  end
119
166
 
@@ -144,6 +191,8 @@ module Pgbus
144
191
  fork_worker(info[:config])
145
192
  when :dispatcher
146
193
  fork_dispatcher
194
+ when :scheduler
195
+ fork_scheduler
147
196
  when :consumer
148
197
  fork_consumer(info[:config])
149
198
  end
@@ -183,7 +232,7 @@ module Pgbus
183
232
 
184
233
  until @forks.empty? || Time.now > deadline
185
234
  reap_children
186
- sleep(0.5)
235
+ interruptible_sleep(0.5)
187
236
  end
188
237
 
189
238
  # Force kill any remaining
@@ -28,6 +28,7 @@ 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
@@ -56,29 +57,31 @@ module Pgbus
56
57
 
57
58
  def claim_and_execute
58
59
  idle = @pool.max_length - @pool.queue_length
59
- return sleep(config.polling_interval) if idle <= 0
60
+ return interruptible_sleep(config.polling_interval) if idle <= 0
60
61
 
61
- messages = fetch_messages(idle)
62
+ tagged_messages = fetch_messages(idle)
62
63
 
63
- if messages.empty?
64
- sleep(config.polling_interval)
64
+ if tagged_messages.empty?
65
+ interruptible_sleep(config.polling_interval)
65
66
  return
66
67
  end
67
68
 
68
- messages.each do |message|
69
- queue_name = message.respond_to?(:queue_name) ? message.queue_name : queues.first
69
+ tagged_messages.each do |queue_name, message|
70
70
  @pool.post { process_message(message, queue_name) }
71
71
  end
72
72
  end
73
73
 
74
+ # Returns an array of [queue_name, message] pairs so we always know
75
+ # which queue each message came from (PGMQ messages don't carry this).
74
76
  def fetch_messages(qty)
75
77
  if queues.size == 1
76
- Pgbus.client.read_batch(queues.first, qty: qty) || []
78
+ queue = queues.first
79
+ messages = Pgbus.client.read_batch(queue, qty: qty) || []
80
+ messages.map { |m| [queue, m] }
77
81
  else
78
- # Multi-queue read: read from each queue proportionally
79
82
  per_queue = [(qty / queues.size.to_f).ceil, 1].max
80
83
  queues.flat_map do |q|
81
- Pgbus.client.read_batch(q, qty: per_queue) || []
84
+ (Pgbus.client.read_batch(q, qty: per_queue) || []).map { |m| [q, m] }
82
85
  end.first(qty)
83
86
  end
84
87
  rescue StandardError => e
@@ -95,6 +98,31 @@ module Pgbus
95
98
  Pgbus.logger.error { "[Pgbus] Unhandled error processing message: #{e.message}" }
96
99
  end
97
100
 
101
+ # Resolve "*" to all non-DLQ queues from pgmq.meta, stripping the prefix.
102
+ # Called once at startup. If no wildcard, this is a no-op.
103
+ def resolve_wildcard_queues
104
+ return unless @queues.include?("*")
105
+
106
+ dlq_suffix = config.dead_letter_queue_suffix
107
+ prefix = "#{config.queue_prefix}_"
108
+
109
+ all_queues = ActiveRecord::Base.connection.select_values("SELECT queue_name FROM pgmq.meta ORDER BY queue_name")
110
+ resolved = all_queues
111
+ .reject { |q| q.end_with?(dlq_suffix) }
112
+ .map { |q| q.delete_prefix(prefix) }
113
+
114
+ if resolved.empty?
115
+ Pgbus.logger.warn { "[Pgbus] Wildcard queue '*' resolved to no queues — falling back to default" }
116
+ @queues = [config.default_queue]
117
+ else
118
+ @queues = resolved
119
+ Pgbus.logger.info { "[Pgbus] Wildcard queue '*' resolved to: #{@queues.join(", ")}" }
120
+ end
121
+ rescue StandardError => e
122
+ Pgbus.logger.error { "[Pgbus] Failed to resolve wildcard queues: #{e.message} — falling back to default" }
123
+ @queues = [config.default_queue]
124
+ end
125
+
98
126
  def recycle_needed?
99
127
  exceeded_max_jobs? || exceeded_max_memory? || exceeded_max_lifetime?
100
128
  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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Recurring
5
+ # Job class for command-based recurring tasks.
6
+ # Executes a Ruby command string, similar to solid_queue's RecurringJob.
7
+ #
8
+ # NOTE: Only use this with trusted commands from config/recurring.yml.
9
+ # Never expose this to user input.
10
+ class CommandJob < ::ActiveJob::Base
11
+ def perform(command)
12
+ eval(command) # rubocop:disable Security/Eval
13
+ end
14
+ end
15
+ end
16
+ 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