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.
- 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 +3 -6
- data/lib/pgbus/active_job/executor.rb +26 -12
- data/lib/pgbus/batch.rb +65 -72
- data/lib/pgbus/cli.rb +11 -16
- data/lib/pgbus/client.rb +32 -15
- 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 +48 -0
- data/lib/pgbus/engine.rb +19 -1
- data/lib/pgbus/event_bus/handler.rb +10 -23
- 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 +17 -9
- data/lib/pgbus/process/dispatcher.rb +33 -41
- data/lib/pgbus/process/heartbeat.rb +15 -23
- data/lib/pgbus/process/signal_handler.rb +23 -1
- data/lib/pgbus/process/supervisor.rb +79 -2
- data/lib/pgbus/process/worker.rb +42 -13
- data/lib/pgbus/recurring/already_recorded.rb +7 -0
- data/lib/pgbus/recurring/command_job.rb +28 -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 +16 -6
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +217 -36
- 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,29 @@ 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
|
+
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
|
|
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
|
|
@@ -57,54 +59,37 @@ module Pgbus
|
|
|
57
59
|
def run_maintenance
|
|
58
60
|
now = Time.now
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
127
|
-
|
|
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 :
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
263
|
+
interruptible_sleep(0.5)
|
|
187
264
|
end
|
|
188
265
|
|
|
189
266
|
# Force kill any remaining
|