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
|
@@ -8,57 +8,29 @@ module Pgbus
|
|
|
8
8
|
# Returns :acquired if a slot was available, :blocked if the limit is reached.
|
|
9
9
|
def acquire(key, max_value, duration)
|
|
10
10
|
expires_at = Time.now.utc + duration
|
|
11
|
-
|
|
12
|
-
result = execute(<<~SQL, "Pgbus Semaphore Acquire", [key, max_value, expires_at])
|
|
13
|
-
INSERT INTO pgbus_semaphores (key, value, max_value, expires_at)
|
|
14
|
-
VALUES ($1, 1, $2, $3)
|
|
15
|
-
ON CONFLICT (key) DO UPDATE
|
|
16
|
-
SET value = pgbus_semaphores.value + 1,
|
|
17
|
-
max_value = EXCLUDED.max_value,
|
|
18
|
-
expires_at = GREATEST(pgbus_semaphores.expires_at, EXCLUDED.expires_at)
|
|
19
|
-
WHERE pgbus_semaphores.value < pgbus_semaphores.max_value
|
|
20
|
-
RETURNING value
|
|
21
|
-
SQL
|
|
22
|
-
|
|
23
|
-
result.any? ? :acquired : :blocked
|
|
11
|
+
Pgbus::Semaphore.acquire!(key, max_value, expires_at)
|
|
24
12
|
end
|
|
25
13
|
|
|
26
14
|
# Release one slot in the semaphore. Called after a job completes.
|
|
27
15
|
def release(key)
|
|
28
|
-
|
|
29
|
-
UPDATE pgbus_semaphores
|
|
30
|
-
SET value = GREATEST(value - 1, 0)
|
|
31
|
-
WHERE key = $1
|
|
32
|
-
SQL
|
|
16
|
+
Pgbus::Semaphore.where(key: key).update_all("value = GREATEST(value - 1, 0)")
|
|
33
17
|
end
|
|
34
18
|
|
|
35
19
|
# Delete semaphores that have expired (safety net for crashed workers).
|
|
36
|
-
# Returns
|
|
20
|
+
# Returns an array of hashes with expired keys.
|
|
21
|
+
# Uses DELETE ... RETURNING for atomicity (no race between pluck and delete).
|
|
37
22
|
def expire_stale
|
|
38
|
-
result =
|
|
39
|
-
DELETE FROM pgbus_semaphores
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
result.to_a
|
|
23
|
+
result = Pgbus::Semaphore.connection.exec_query(
|
|
24
|
+
"DELETE FROM pgbus_semaphores WHERE expires_at < $1 RETURNING key",
|
|
25
|
+
"Pgbus Semaphore Expire",
|
|
26
|
+
[Time.now.utc]
|
|
27
|
+
)
|
|
28
|
+
result.rows.map { |row| { "key" => row[0] } }
|
|
45
29
|
end
|
|
46
30
|
|
|
47
31
|
# Check current value for a key. Useful for testing/monitoring.
|
|
48
32
|
def current_value(key)
|
|
49
|
-
|
|
50
|
-
SELECT value FROM pgbus_semaphores WHERE key = $1
|
|
51
|
-
SQL
|
|
52
|
-
|
|
53
|
-
result.first&.fetch("value", nil)&.to_i
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def execute(sql, name, binds)
|
|
59
|
-
return [] unless defined?(ActiveRecord::Base)
|
|
60
|
-
|
|
61
|
-
ActiveRecord::Base.connection.exec_query(sql, name, binds)
|
|
33
|
+
Pgbus::Semaphore.where(key: key).pick(:value)
|
|
62
34
|
end
|
|
63
35
|
end
|
|
64
36
|
end
|
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -22,13 +22,15 @@ module Pgbus
|
|
|
22
22
|
def limits_concurrency(to:, key: nil, duration: 15 * 60, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
|
|
23
23
|
raise ArgumentError, "to: must be a positive integer" unless to.is_a?(Integer) && to.positive?
|
|
24
24
|
raise ArgumentError, "on_conflict must be :block, :discard, or :raise" unless %i[block discard raise].include?(on_conflict)
|
|
25
|
+
raise ArgumentError, "duration must be a positive number" unless duration.is_a?(Numeric) && duration.positive?
|
|
26
|
+
raise ArgumentError, "key must be callable (Proc or lambda)" if key && !key.respond_to?(:call)
|
|
25
27
|
|
|
26
28
|
@pgbus_concurrency = {
|
|
27
29
|
limit: to,
|
|
28
30
|
key: key || ->(*) { name },
|
|
29
31
|
duration: duration,
|
|
30
32
|
on_conflict: on_conflict
|
|
31
|
-
}
|
|
33
|
+
}.freeze
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def pgbus_concurrency
|
|
@@ -45,7 +47,13 @@ module Pgbus
|
|
|
45
47
|
config = active_job.class.pgbus_concurrency
|
|
46
48
|
return nil unless config
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
args = active_job.arguments
|
|
51
|
+
last = args.last
|
|
52
|
+
if last.is_a?(Hash) && last.each_key.all?(Symbol)
|
|
53
|
+
config[:key].call(*args[...-1], **last)
|
|
54
|
+
else
|
|
55
|
+
config[:key].call(*args)
|
|
56
|
+
end
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
# Inject the resolved concurrency key into the job's serialized payload.
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -31,9 +31,21 @@ module Pgbus
|
|
|
31
31
|
# LISTEN/NOTIFY
|
|
32
32
|
attr_accessor :listen_notify, :notify_throttle_ms
|
|
33
33
|
|
|
34
|
+
# PGMQ schema installation mode (:auto, :extension, :embedded)
|
|
35
|
+
attr_reader :pgmq_schema_mode
|
|
36
|
+
|
|
34
37
|
# Event consumers
|
|
35
38
|
attr_accessor :event_consumers
|
|
36
39
|
|
|
40
|
+
# Recurring jobs
|
|
41
|
+
attr_accessor :recurring_tasks, :recurring_schedule_interval, :recurring_tasks_file,
|
|
42
|
+
:skip_recurring, :recurring_execution_retention
|
|
43
|
+
|
|
44
|
+
# Multi-database support (optional separate database for pgbus tables)
|
|
45
|
+
# Set to { database: { writing: :pgbus, reading: :pgbus } } to use a separate database.
|
|
46
|
+
# Requires a matching entry in config/database.yml under the "pgbus" key.
|
|
47
|
+
attr_accessor :connects_to
|
|
48
|
+
|
|
37
49
|
# Web dashboard
|
|
38
50
|
attr_accessor :web_auth, :web_refresh_interval, :web_per_page, :web_live_updates, :web_data_source
|
|
39
51
|
|
|
@@ -66,8 +78,18 @@ module Pgbus
|
|
|
66
78
|
@listen_notify = true
|
|
67
79
|
@notify_throttle_ms = 250
|
|
68
80
|
|
|
81
|
+
@pgmq_schema_mode = :auto
|
|
82
|
+
|
|
69
83
|
@event_consumers = nil
|
|
70
84
|
|
|
85
|
+
@recurring_tasks = nil
|
|
86
|
+
@recurring_schedule_interval = 1.0
|
|
87
|
+
@recurring_tasks_file = nil
|
|
88
|
+
@skip_recurring = false
|
|
89
|
+
@recurring_execution_retention = 7 * 24 * 3600 # 7 days
|
|
90
|
+
|
|
91
|
+
@connects_to = nil
|
|
92
|
+
|
|
71
93
|
@web_auth = nil
|
|
72
94
|
@web_refresh_interval = 5000
|
|
73
95
|
@web_per_page = 25
|
|
@@ -83,6 +105,32 @@ module Pgbus
|
|
|
83
105
|
"#{queue_name(name)}#{dead_letter_queue_suffix}"
|
|
84
106
|
end
|
|
85
107
|
|
|
108
|
+
VALID_PGMQ_SCHEMA_MODES = %i[auto extension embedded].freeze
|
|
109
|
+
|
|
110
|
+
def pgmq_schema_mode=(mode)
|
|
111
|
+
mode = mode.to_sym
|
|
112
|
+
unless VALID_PGMQ_SCHEMA_MODES.include?(mode)
|
|
113
|
+
raise ArgumentError, "Invalid pgmq_schema_mode: #{mode}. Must be one of: #{VALID_PGMQ_SCHEMA_MODES.join(", ")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@pgmq_schema_mode = mode
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate!
|
|
120
|
+
raise ArgumentError, "pool_size must be > 0" unless pool_size.is_a?(Numeric) && pool_size.positive?
|
|
121
|
+
raise ArgumentError, "pool_timeout must be > 0" unless pool_timeout.is_a?(Numeric) && pool_timeout.positive?
|
|
122
|
+
raise ArgumentError, "polling_interval must be > 0" unless polling_interval.is_a?(Numeric) && polling_interval.positive?
|
|
123
|
+
raise ArgumentError, "visibility_timeout must be > 0" unless visibility_timeout.is_a?(Numeric) && visibility_timeout.positive?
|
|
124
|
+
raise ArgumentError, "max_retries must be >= 0" unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
125
|
+
|
|
126
|
+
workers.each do |w|
|
|
127
|
+
threads = w[:threads] || w["threads"] || 5
|
|
128
|
+
raise ArgumentError, "worker threads must be > 0" unless threads.is_a?(Integer) && threads.positive?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
86
134
|
def connection_options
|
|
87
135
|
if database_url
|
|
88
136
|
database_url
|
data/lib/pgbus/engine.rb
CHANGED
|
@@ -11,9 +11,23 @@ module Pgbus
|
|
|
11
11
|
Pgbus::ConfigLoader.load(config_path) if config_path.exist?
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
initializer "pgbus.recurring" do |app|
|
|
15
|
+
recurring_path = app.root.join("config", "recurring.yml")
|
|
16
|
+
if recurring_path.exist? && !Pgbus.configuration.recurring_tasks
|
|
17
|
+
Pgbus.configuration.recurring_tasks = Pgbus::Recurring::ConfigLoader.load(recurring_path)
|
|
18
|
+
Pgbus.configuration.recurring_tasks_file ||= recurring_path.to_s
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "pgbus.db" do
|
|
23
|
+
ActiveSupport.on_load(:active_record) do
|
|
24
|
+
Pgbus::ApplicationRecord.connects_to(**Pgbus.configuration.connects_to) if Pgbus.configuration.connects_to
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
14
28
|
initializer "pgbus.active_job" do
|
|
15
29
|
ActiveSupport.on_load(:active_job) do
|
|
16
|
-
|
|
30
|
+
include Pgbus::Concurrency
|
|
17
31
|
end
|
|
18
32
|
end
|
|
19
33
|
|
|
@@ -23,6 +37,10 @@ module Pgbus
|
|
|
23
37
|
end
|
|
24
38
|
end
|
|
25
39
|
|
|
40
|
+
rake_tasks do
|
|
41
|
+
load File.expand_path("../tasks/pgbus_pgmq.rake", __dir__)
|
|
42
|
+
end
|
|
43
|
+
|
|
26
44
|
initializer "pgbus.web" do
|
|
27
45
|
require "pgbus/web/authentication"
|
|
28
46
|
require "pgbus/web/data_source"
|
|
@@ -17,11 +17,7 @@ module Pgbus
|
|
|
17
17
|
raw = JSON.parse(message.message)
|
|
18
18
|
event = build_event(raw)
|
|
19
19
|
|
|
20
|
-
if self.class.idempotent?
|
|
21
|
-
return :skipped if already_processed?(event.event_id)
|
|
22
|
-
|
|
23
|
-
mark_processed!(event.event_id)
|
|
24
|
-
end
|
|
20
|
+
return :skipped if self.class.idempotent? && !claim_idempotency?(event.event_id)
|
|
25
21
|
|
|
26
22
|
handle(event)
|
|
27
23
|
instrument("pgbus.event_processed", event_id: event.event_id, handler: self.class.name)
|
|
@@ -51,25 +47,16 @@ module Pgbus
|
|
|
51
47
|
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
52
48
|
end
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
[event_id
|
|
61
|
-
)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def mark_processed!(event_id)
|
|
65
|
-
return unless defined?(ActiveRecord::Base)
|
|
66
|
-
|
|
67
|
-
ActiveRecord::Base.connection.exec_insert(
|
|
68
|
-
"INSERT INTO pgbus_processed_events (event_id, handler_class, processed_at) " \
|
|
69
|
-
"VALUES ($1, $2, $3) ON CONFLICT (event_id, handler_class) DO NOTHING",
|
|
70
|
-
"Pgbus Idempotency Mark",
|
|
71
|
-
[event_id, self.class.name, Time.now.utc]
|
|
50
|
+
# Atomically claim idempotency: INSERT ... ON CONFLICT DO NOTHING.
|
|
51
|
+
# Returns true if this handler claimed the event (row was inserted),
|
|
52
|
+
# false if another handler already processed it (conflict, no insert).
|
|
53
|
+
def claim_idempotency?(event_id)
|
|
54
|
+
result = ProcessedEvent.insert(
|
|
55
|
+
{ event_id: event_id, handler_class: self.class.name, processed_at: Time.now.utc },
|
|
56
|
+
unique_by: %i[event_id handler_class]
|
|
72
57
|
)
|
|
58
|
+
# insert returns an InsertAll::Result; inserted row count > 0 means we claimed it
|
|
59
|
+
result.rows.any?
|
|
73
60
|
end
|
|
74
61
|
end
|
|
75
62
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
# Lightweight instrumentation via ActiveSupport::Notifications.
|
|
5
|
+
#
|
|
6
|
+
# All events are prefixed with "pgbus." and carry timing information
|
|
7
|
+
# automatically when used with the block form of AS::Notifications.instrument.
|
|
8
|
+
#
|
|
9
|
+
# Events emitted:
|
|
10
|
+
# pgbus.client.send_message — single message enqueue
|
|
11
|
+
# pgbus.client.send_batch — batch enqueue
|
|
12
|
+
# pgbus.client.read_batch — batch dequeue
|
|
13
|
+
# pgbus.client.read_message — single message dequeue
|
|
14
|
+
# pgbus.executor.execute — full job execution (deserialize + perform + archive)
|
|
15
|
+
# pgbus.serializer.serialize — job/event serialization
|
|
16
|
+
# pgbus.serializer.deserialize — job/event deserialization
|
|
17
|
+
#
|
|
18
|
+
module Instrumentation
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def instrument(event, payload = {}, &block)
|
|
22
|
+
if defined?(ActiveSupport::Notifications)
|
|
23
|
+
ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
24
|
+
elsif block
|
|
25
|
+
yield payload
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|