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
@@ -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
- execute(<<~SQL, "Pgbus Semaphore Release", [key])
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 the number of deleted rows.
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 = execute(<<~SQL, "Pgbus Semaphore Expire", [Time.now.utc])
39
- DELETE FROM pgbus_semaphores
40
- WHERE expires_at < $1 OR value <= 0
41
- RETURNING key
42
- SQL
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
- result = execute(<<~SQL, "Pgbus Semaphore Value", [key])
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
@@ -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
- config[:key].call(*active_job.arguments)
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.
@@ -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
- require "pgbus/active_job/adapter"
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
- def already_processed?(event_id)
55
- return false unless defined?(ActiveRecord::Base)
56
-
57
- ActiveRecord::Base.connection.select_value(
58
- "SELECT 1 FROM pgbus_processed_events WHERE event_id = $1 AND handler_class = $2",
59
- "Pgbus Idempotency Check",
60
- [event_id, self.class.name]
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