pgbus 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +37 -3
- data/Rakefile +98 -1
- data/app/controllers/pgbus/application_controller.rb +8 -0
- data/app/controllers/pgbus/recurring_tasks_controller.rb +36 -0
- data/app/helpers/pgbus/application_helper.rb +41 -0
- data/app/models/pgbus/application_record.rb +7 -0
- data/app/models/pgbus/batch_entry.rb +31 -0
- data/app/models/pgbus/blocked_execution.rb +40 -0
- data/app/models/pgbus/process_entry.rb +9 -0
- data/app/models/pgbus/processed_event.rb +9 -0
- data/app/models/pgbus/recurring_execution.rb +33 -0
- data/app/models/pgbus/recurring_task.rb +42 -0
- data/app/models/pgbus/semaphore.rb +29 -0
- data/app/views/layouts/pgbus/application.html.erb +1 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +9 -1
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +55 -18
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +46 -8
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +79 -0
- data/app/views/pgbus/recurring_tasks/index.html.erb +6 -0
- data/app/views/pgbus/recurring_tasks/show.html.erb +122 -0
- data/config/routes.rb +7 -0
- data/lib/active_job/queue_adapters/pgbus_adapter.rb +29 -0
- data/lib/generators/pgbus/add_recurring_generator.rb +56 -0
- data/lib/generators/pgbus/install_generator.rb +76 -2
- data/lib/generators/pgbus/templates/add_recurring_tables.rb.erb +31 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +72 -4
- data/lib/generators/pgbus/templates/recurring.yml.erb +40 -0
- data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +30 -0
- data/lib/generators/pgbus/upgrade_pgmq_generator.rb +60 -0
- data/lib/pgbus/active_job/adapter.rb +0 -3
- data/lib/pgbus/active_job/executor.rb +27 -12
- data/lib/pgbus/batch.rb +60 -69
- data/lib/pgbus/cli.rb +11 -16
- data/lib/pgbus/client.rb +25 -7
- data/lib/pgbus/concurrency/blocked_execution.rb +32 -37
- data/lib/pgbus/concurrency/semaphore.rb +11 -39
- data/lib/pgbus/concurrency.rb +10 -2
- data/lib/pgbus/configuration.rb +33 -0
- data/lib/pgbus/engine.rb +19 -1
- data/lib/pgbus/event_bus/handler.rb +4 -14
- data/lib/pgbus/instrumentation.rb +29 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql +2123 -0
- data/lib/pgbus/pgmq_schema.rb +159 -0
- data/lib/pgbus/process/consumer.rb +8 -9
- data/lib/pgbus/process/dispatcher.rb +26 -24
- data/lib/pgbus/process/heartbeat.rb +15 -23
- data/lib/pgbus/process/signal_handler.rb +23 -1
- data/lib/pgbus/process/supervisor.rb +51 -2
- data/lib/pgbus/process/worker.rb +37 -9
- data/lib/pgbus/recurring/already_recorded.rb +7 -0
- data/lib/pgbus/recurring/command_job.rb +16 -0
- data/lib/pgbus/recurring/config_loader.rb +35 -0
- data/lib/pgbus/recurring/schedule.rb +102 -0
- data/lib/pgbus/recurring/scheduler.rb +102 -0
- data/lib/pgbus/recurring/task.rb +111 -0
- data/lib/pgbus/serializer.rb +10 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +187 -22
- data/lib/pgbus.rb +8 -0
- data/lib/tasks/pgbus_pgmq.rake +62 -0
- metadata +51 -24
- data/.bun-version +0 -1
- data/.claude/commands/architect.md +0 -100
- data/.claude/commands/github-review-comments.md +0 -237
- data/.claude/commands/lfg.md +0 -271
- data/.claude/commands/review-pr.md +0 -69
- data/.claude/commands/security.md +0 -122
- data/.claude/commands/tdd.md +0 -148
- data/.claude/rules/agents.md +0 -49
- data/.claude/rules/coding-style.md +0 -91
- data/.claude/rules/git-workflow.md +0 -56
- data/.claude/rules/performance.md +0 -73
- data/.claude/rules/testing.md +0 -67
- data/CLAUDE.md +0 -80
- data/CODE_OF_CONDUCT.md +0 -10
- data/bun.lock +0 -18
- data/docs/README.md +0 -28
- data/docs/switch_from_good_job.md +0 -279
- data/docs/switch_from_sidekiq.md +0 -226
- data/docs/switch_from_solid_queue.md +0 -247
- data/package.json +0 -9
- data/sig/pgbus.rbs +0 -4
|
@@ -0,0 +1,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
|
|
57
|
+
return interruptible_sleep(config.polling_interval) if idle <= 0
|
|
58
58
|
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
63
|
+
if tagged_messages.empty?
|
|
64
|
+
interruptible_sleep(config.polling_interval)
|
|
65
65
|
return
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
|
|
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
|
|
10
|
-
REAP_INTERVAL = 300
|
|
11
|
-
CONCURRENCY_INTERVAL = 300
|
|
12
|
-
BATCH_CLEANUP_INTERVAL = 3600
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
127
|
-
|
|
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 :
|
|
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
|
|
32
|
+
return unless @process_id
|
|
32
33
|
|
|
33
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
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
|
|
55
|
+
return unless @process_id
|
|
60
56
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
235
|
+
interruptible_sleep(0.5)
|
|
187
236
|
end
|
|
188
237
|
|
|
189
238
|
# Force kill any remaining
|
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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
|
|
60
|
+
return interruptible_sleep(config.polling_interval) if idle <= 0
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
tagged_messages = fetch_messages(idle)
|
|
62
63
|
|
|
63
|
-
if
|
|
64
|
-
|
|
64
|
+
if tagged_messages.empty?
|
|
65
|
+
interruptible_sleep(config.polling_interval)
|
|
65
66
|
return
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
|
|
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
|
-
|
|
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,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
|