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
@@ -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,29 @@ 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
+ if message.read_ct.to_i > config.max_retries
75
+ Pgbus.logger.warn { "[Pgbus] Consumer moving message #{message.msg_id} to DLQ after #{message.read_ct} reads" }
76
+ Pgbus.client.move_to_dead_letter(queue_name, message)
77
+ return
78
+ end
79
+
74
80
  raw = JSON.parse(message.message)
75
81
  routing_key = raw.dig("headers", "routing_key") || raw["routing_key"]
76
82
 
@@ -80,10 +86,12 @@ module Pgbus
80
86
  handler.process(message)
81
87
  end
82
88
 
83
- queue_name = message.respond_to?(:queue_name) ? message.queue_name : @queue_names.first
84
89
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
85
90
  rescue StandardError => e
86
91
  Pgbus.logger.error { "[Pgbus] Consumer error: #{e.class}: #{e.message}" }
92
+ # Message stays in queue; VT will expire and it becomes available again.
93
+ # read_ct tracks delivery attempts — when it exceeds max_retries,
94
+ # the next read will route to DLQ above.
87
95
  end
88
96
 
89
97
  def pattern_overlaps?(topic_filter, subscription_pattern)
@@ -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
@@ -57,54 +59,37 @@ module Pgbus
57
59
  def run_maintenance
58
60
  now = Time.now
59
61
 
60
- if now - @last_cleanup_at >= CLEANUP_INTERVAL
61
- cleanup_processed_events
62
- @last_cleanup_at = now
63
- end
62
+ run_if_due(now, :@last_cleanup_at, CLEANUP_INTERVAL) { cleanup_processed_events }
63
+ run_if_due(now, :@last_reap_at, REAP_INTERVAL) { reap_stale_processes }
64
+ run_if_due(now, :@last_concurrency_at, CONCURRENCY_INTERVAL) { cleanup_concurrency }
65
+ run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
66
+ run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
67
+ end
64
68
 
65
- if now - @last_reap_at >= REAP_INTERVAL
66
- reap_stale_processes
67
- @last_reap_at = now
68
- end
69
+ # Only update the timestamp when the block succeeds.
70
+ # On failure, the next tick retries instead of waiting the full interval.
71
+ def run_if_due(now, ivar, interval)
72
+ return unless now - instance_variable_get(ivar) >= interval
69
73
 
70
- if now - @last_concurrency_at >= CONCURRENCY_INTERVAL
71
- cleanup_concurrency
72
- @last_concurrency_at = now
73
- end
74
-
75
- if now - @last_batch_cleanup_at >= BATCH_CLEANUP_INTERVAL
76
- cleanup_batches
77
- @last_batch_cleanup_at = now
78
- end
74
+ yield
75
+ instance_variable_set(ivar, now)
79
76
  rescue StandardError => e
80
77
  Pgbus.logger.error { "[Pgbus] Dispatcher maintenance error: #{e.message}" }
81
78
  end
82
79
 
83
80
  def cleanup_processed_events
84
- return unless defined?(ActiveRecord::Base)
85
-
86
81
  ttl = config.idempotency_ttl
87
82
  return unless ttl&.positive?
88
83
 
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
- )
84
+ deleted = ProcessedEvent.expired(Time.now.utc - ttl).delete_all
94
85
  Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} expired processed events" } if deleted.positive?
95
86
  rescue StandardError => e
96
87
  Pgbus.logger.warn { "[Pgbus] Idempotency cleanup failed: #{e.message}" }
97
88
  end
98
89
 
99
90
  def reap_stale_processes
100
- return unless defined?(ActiveRecord::Base)
101
-
102
91
  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
- )
92
+ deleted = ProcessEntry.stale(Time.now.utc - threshold).delete_all
108
93
  Pgbus.logger.info { "[Pgbus] Reaped #{deleted} stale processes" } if deleted.positive?
109
94
  rescue StandardError => e
110
95
  Pgbus.logger.warn { "[Pgbus] Stale process reaping failed: #{e.message}" }
@@ -123,11 +108,8 @@ module Pgbus
123
108
  end
124
109
 
125
110
  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}" }
111
+ promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
112
+ Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
131
113
  rescue StandardError => e
132
114
  Pgbus.logger.warn { "[Pgbus] Failed to release blocked execution for #{key}: #{e.message}" }
133
115
  end
@@ -139,6 +121,16 @@ module Pgbus
139
121
  Pgbus.logger.warn { "[Pgbus] Batch cleanup failed: #{e.message}" }
140
122
  end
141
123
 
124
+ def cleanup_recurring_executions
125
+ retention = config.recurring_execution_retention
126
+ return unless retention&.positive?
127
+
128
+ deleted = RecurringExecution.older_than(Time.now.utc - retention).delete_all
129
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old recurring executions" } if deleted.positive?
130
+ rescue StandardError => e
131
+ Pgbus.logger.warn { "[Pgbus] Recurring execution cleanup failed: #{e.message}" }
132
+ end
133
+
142
134
  def start_heartbeat
143
135
  @heartbeat = Heartbeat.new(kind: "dispatcher", metadata: { pid: ::Process.pid })
144
136
  @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
@@ -66,8 +69,15 @@ module Pgbus
66
69
  worker.run
67
70
  end
68
71
 
72
+ unless pid
73
+ Pgbus.logger.error { "[Pgbus] Failed to fork worker for queues=#{queues.join(",")}" }
74
+ return
75
+ end
76
+
69
77
  @forks[pid] = { type: :worker, config: worker_config }
70
78
  Pgbus.logger.info { "[Pgbus] Forked worker pid=#{pid} queues=#{queues.join(",")}" }
79
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
80
+ Pgbus.logger.error { "[Pgbus] Fork failed for worker: #{e.message}" }
71
81
  end
72
82
 
73
83
  def fork_dispatcher
@@ -79,8 +89,66 @@ module Pgbus
79
89
  dispatcher.run
80
90
  end
81
91
 
92
+ unless pid
93
+ Pgbus.logger.error { "[Pgbus] Failed to fork dispatcher" }
94
+ return
95
+ end
96
+
82
97
  @forks[pid] = { type: :dispatcher }
83
98
  Pgbus.logger.info { "[Pgbus] Forked dispatcher pid=#{pid}" }
99
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
100
+ Pgbus.logger.error { "[Pgbus] Fork failed for dispatcher: #{e.message}" }
101
+ end
102
+
103
+ def boot_scheduler
104
+ return if config.skip_recurring
105
+ return unless recurring_tasks_configured?
106
+
107
+ fork_scheduler
108
+ end
109
+
110
+ def fork_scheduler
111
+ pid = fork do
112
+ restore_signals
113
+ setup_child_signals
114
+ load_rails_app
115
+ load_recurring_config
116
+ scheduler = Recurring::Scheduler.new(config: config)
117
+ scheduler.run
118
+ end
119
+
120
+ unless pid
121
+ Pgbus.logger.error { "[Pgbus] Failed to fork scheduler" }
122
+ return
123
+ end
124
+
125
+ @forks[pid] = { type: :scheduler }
126
+ Pgbus.logger.info { "[Pgbus] Forked scheduler pid=#{pid}" }
127
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
128
+ Pgbus.logger.error { "[Pgbus] Fork failed for scheduler: #{e.message}" }
129
+ end
130
+
131
+ def recurring_tasks_configured?
132
+ return true if config.recurring_tasks&.any?
133
+ return true if config.recurring_tasks_file && File.exist?(config.recurring_tasks_file.to_s)
134
+
135
+ # Check default location
136
+ if defined?(Rails)
137
+ default_path = Rails.root.join("config", "recurring.yml")
138
+ return File.exist?(default_path.to_s)
139
+ end
140
+
141
+ false
142
+ end
143
+
144
+ def load_recurring_config
145
+ return if config.recurring_tasks&.any?
146
+
147
+ path = config.recurring_tasks_file
148
+ path ||= defined?(Rails) ? Rails.root.join("config", "recurring.yml") : nil
149
+ return unless path && File.exist?(path.to_s)
150
+
151
+ config.recurring_tasks = Recurring::ConfigLoader.load(path)
84
152
  end
85
153
 
86
154
  def boot_consumers
@@ -103,8 +171,15 @@ module Pgbus
103
171
  consumer.run
104
172
  end
105
173
 
174
+ unless pid
175
+ Pgbus.logger.error { "[Pgbus] Failed to fork consumer for topics=#{topics.join(",")}" }
176
+ return
177
+ end
178
+
106
179
  @forks[pid] = { type: :consumer, config: consumer_config }
107
180
  Pgbus.logger.info { "[Pgbus] Forked consumer pid=#{pid} topics=#{topics.join(",")}" }
181
+ rescue Errno::EAGAIN, Errno::ENOMEM => e
182
+ Pgbus.logger.error { "[Pgbus] Fork failed for consumer: #{e.message}" }
108
183
  end
109
184
 
110
185
  def monitor_loop
@@ -113,7 +188,7 @@ module Pgbus
113
188
 
114
189
  process_signals
115
190
  reap_children
116
- sleep(FORK_WAIT)
191
+ interruptible_sleep(FORK_WAIT)
117
192
  end
118
193
  end
119
194
 
@@ -144,6 +219,8 @@ module Pgbus
144
219
  fork_worker(info[:config])
145
220
  when :dispatcher
146
221
  fork_dispatcher
222
+ when :scheduler
223
+ fork_scheduler
147
224
  when :consumer
148
225
  fork_consumer(info[:config])
149
226
  end
@@ -183,7 +260,7 @@ module Pgbus
183
260
 
184
261
  until @forks.empty? || Time.now > deadline
185
262
  reap_children
186
- sleep(0.5)
263
+ interruptible_sleep(0.5)
187
264
  end
188
265
 
189
266
  # Force kill any remaining