pgbus 0.9.8 → 0.9.9

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +336 -25
  4. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  5. data/lib/pgbus/active_job/adapter.rb +1 -1
  6. data/lib/pgbus/config_loader.rb +29 -2
  7. data/lib/pgbus/configuration.rb +202 -73
  8. data/lib/pgbus/doctor.rb +28 -3
  9. data/lib/pgbus/execution_pools/async_pool.rb +2 -2
  10. data/lib/pgbus/generators/config_converter.rb +7 -5
  11. data/lib/pgbus/generators/migration_detector.rb +20 -16
  12. data/lib/pgbus/instrumentation.rb +2 -1
  13. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  14. data/lib/pgbus/metrics/subscriber.rb +26 -2
  15. data/lib/pgbus/metrics.rb +3 -2
  16. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  17. data/lib/pgbus/pgmq_schema.rb +7 -2
  18. data/lib/pgbus/process/consumer.rb +141 -12
  19. data/lib/pgbus/process/supervisor.rb +33 -10
  20. data/lib/pgbus/process/worker.rb +6 -16
  21. data/lib/pgbus/recurring/schedule.rb +1 -2
  22. data/lib/pgbus/serializer.rb +4 -4
  23. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  24. data/lib/pgbus/streams/signed_name.rb +2 -2
  25. data/lib/pgbus/uniqueness.rb +11 -12
  26. data/lib/pgbus/version.rb +1 -1
  27. data/lib/pgbus/web/data_source.rb +20 -4
  28. data/lib/pgbus/web/payload_filter.rb +3 -3
  29. data/lib/pgbus/web/streamer/instance.rb +6 -4
  30. data/lib/pgbus/web/streamer/listener.rb +21 -36
  31. data/lib/pgbus.rb +46 -6
  32. metadata +4 -6
  33. data/app/models/pgbus/job_lock.rb +0 -98
  34. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
  35. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +0 -42
  36. data/lib/rubocop/pgbus.rb +0 -5
@@ -19,10 +19,11 @@ module Pgbus
19
19
  #
20
20
  # Health check: `wait_for_notify(timeout)` returns nil on timeout. When
21
21
  # it does, the listener runs `SELECT 1` as a TCP keepalive. If that
22
- # raises, the connection is reset (`conn.reset`) and every channel in
23
- # `@listening_to` is re-LISTENed. This is the fix for design doc §11 #1
24
- # (silently dropped LISTEN connections from NAT / PG restart / network
25
- # blips).
22
+ # raises, the listener rebuilds a FRESH connection via connection_factory
23
+ # and re-LISTENs every channel in `@listening_to`. This is the fix for
24
+ # design doc §11 #1 (silently dropped LISTEN connections from NAT / PG
25
+ # restart / network blips) — a fresh connect also re-resolves DNS and
26
+ # converges on the promoted primary after a failover.
26
27
  #
27
28
  # NOTIFY channel naming (from pgmq_v1.11.0.sql:1634):
28
29
  # PG_NOTIFY('pgmq.' || TG_TABLE_NAME || '.' || TG_OP, NULL)
@@ -42,12 +43,15 @@ module Pgbus
42
43
 
43
44
  attr_reader :listening_to
44
45
 
45
- # @param connection_factory [#call, nil] builds a FRESH PG connection on
46
- # each reconnect attempt (the Instance passes -> { build_pg_connection }).
47
- # When nil (tests injecting only pg_connection:), reconnect falls back to
48
- # the legacy single-shot @conn.reset.
46
+ # @param connection_factory [#call] builds a FRESH PG connection on each
47
+ # reconnect attempt (the Instance passes -> { build_raw_pg_connection }).
48
+ # Required the reconnect loop always rebuilds rather than resetting a
49
+ # possibly-dead socket, so every caller (production and tests) must pass
50
+ # one.
49
51
  def initialize(pg_connection:, dispatch_queue:, health_check_ms:,
50
- logger: Pgbus.logger, connection_factory: nil)
52
+ connection_factory:, logger: Pgbus.logger)
53
+ raise ArgumentError, "connection_factory is required" unless connection_factory.respond_to?(:call)
54
+
51
55
  @conn = pg_connection
52
56
  @dispatch_queue = dispatch_queue
53
57
  @health_check_ms = health_check_ms
@@ -210,11 +214,8 @@ module Pgbus
210
214
  # NotifyListener#reconnect!: a single failed attempt never returns with
211
215
  # a dead connection while running, so ensure_listening acks can't
212
216
  # succeed vacuously against a broken LISTEN socket and strand SSE
213
- # clients. When no connection_factory was injected (tests passing only
214
- # pg_connection:), fall back to the legacy single-shot conn.reset.
217
+ # clients.
215
218
  def reconnect!
216
- return reconnect_via_reset unless @connection_factory
217
-
218
219
  # Snapshot the canonical subscription set BEFORE tearing down the old
219
220
  # connection. We rebuild @listening_to from this and only publish it
220
221
  # once every channel has been re-LISTENed on the new conn, so a
@@ -234,10 +235,13 @@ module Pgbus
234
235
  # primary; re-LISTENing on a replica registers channels that never wake.
235
236
  Pgbus::Process::PrimaryValidator.validate_primary!(new_conn)
236
237
  channels.each { |channel| new_conn.exec(%(LISTEN "#{channel}")) }
237
- rescue PG::Error, Pgbus::Process::ReplicaConnectionError => e
238
- # The factory may have returned a live conn before a later LISTEN
239
- # raised (or validate_primary! rejected a replica). Close the
240
- # partial conn so repeated failures don't leak PG connections.
238
+ rescue PG::Error, Pgbus::Process::ReplicaConnectionError, Pgbus::ConfigurationError => e
239
+ # The factory may raise a ConfigurationError (a bad
240
+ # streams_connection_options, e.g. a pooled AR connection) or return
241
+ # a live conn before a later LISTEN raised (or validate_primary!
242
+ # rejected a replica). Close any partial conn so repeated failures
243
+ # don't leak PG connections, then back off and retry rather than
244
+ # letting the exception kill the listener thread silently.
241
245
  close_quietly(new_conn)
242
246
  @logger.error { "[Pgbus::Streamer::Listener] reconnect failed: #{e.class}: #{e.message}" }
243
247
  sleep RECONNECT_BACKOFF_SECONDS
@@ -250,25 +254,6 @@ module Pgbus
250
254
  end
251
255
  end
252
256
 
253
- # Legacy fallback for tests that inject only pg_connection: (no factory).
254
- # A single conn.reset attempt; on failure it logs and backs off, leaving
255
- # recovery to the next run_loop cycle. Kept for backwards compatibility
256
- # with test wiring; production always injects a connection_factory.
257
- def reconnect_via_reset
258
- @conn.reset
259
- Pgbus::Process::PrimaryValidator.validate_primary!(@conn)
260
- to_relisten = @listening_to.to_a
261
- new_listening = Set.new
262
- to_relisten.each do |channel|
263
- @conn.exec(%(LISTEN "#{channel}"))
264
- new_listening.add(channel)
265
- end
266
- @listening_to = new_listening
267
- rescue PG::Error, Pgbus::Process::ReplicaConnectionError => e
268
- @logger.error { "[Pgbus::Streamer::Listener] reconnect failed: #{e.class}: #{e.message}" }
269
- sleep RECONNECT_BACKOFF_SECONDS
270
- end
271
-
272
257
  # Close a PG connection we're discarding (the old conn at the top of a
273
258
  # reconnect cycle, or a half-built one whose LISTEN raised). Best-effort.
274
259
  def close_quietly(conn)
data/lib/pgbus.rb CHANGED
@@ -10,6 +10,24 @@ module Pgbus
10
10
  # this to be configurable.
11
11
  DEAD_LETTER_SUFFIX = "_dlq"
12
12
 
13
+ # Error-hierarchy policy (the 1.0 contract, issue #282):
14
+ #
15
+ # * OPERATIONAL errors — a pgbus subsystem failed to do its job at runtime
16
+ # (bad configuration, a pool that's shutting down, a batch enqueue that
17
+ # came back short, a rejected GlobalID, a missing PGMQ version) — descend
18
+ # from Pgbus::Error. `rescue Pgbus::Error` is meant to catch every one of
19
+ # them; that is the whole point of the hierarchy.
20
+ #
21
+ # * ARGUMENT-SHAPE errors — the caller handed a method a malformed argument
22
+ # (a stream name too long for the queue-name budget, an unparseable
23
+ # cursor, a capsule DSL line that doesn't parse) — stay ArgumentError
24
+ # subclasses. That's what ArgumentError means, and rescuing it is the
25
+ # caller's responsibility, not an operational concern. These live in
26
+ # their own files: Streams::StreamNameTooLong, Streams::Cursor::InvalidCursor,
27
+ # Configuration::CapsuleDSL::ParseError.
28
+ #
29
+ # When adding a new error class, decide which half it belongs to and parent
30
+ # it accordingly — do not default to StandardError.
13
31
  class Error < StandardError; end
14
32
  class ConfigurationError < Error; end
15
33
  class SerializationError < Error; end
@@ -20,6 +38,16 @@ module Pgbus
20
38
  class SchemaNotReady < Error; end
21
39
  class ReadTimeoutError < Error; end
22
40
 
41
+ # Raised by the ActiveJob adapter when a batch enqueue (perform_all_later)
42
+ # comes back with a msg_id count that doesn't match the number of jobs sent —
43
+ # a data-integrity signal that some jobs may not have been persisted.
44
+ class EnqueueError < Error; end
45
+
46
+ # Raised by the execution pools when work can't be accepted: the pool is
47
+ # shutting down, or it's momentarily at capacity. Consumer-reachable during
48
+ # job submission, so it descends from Pgbus::Error rather than RuntimeError.
49
+ class ExecutionPoolError < Error; end
50
+
23
51
  # Raised by Client read paths when the in-memory connection-health circuit
24
52
  # breaker (Client::ConnectionHealth) is open: the database has failed enough
25
53
  # consecutive connection attempts that reads now fail fast without a pool
@@ -85,12 +113,6 @@ module Pgbus
85
113
  # root and tries to autoload Puma::Plugin, which collides with the real
86
114
  # Puma::Plugin class defined by the puma gem itself.
87
115
  loader.ignore("#{__dir__}/puma")
88
- # lib/rubocop holds pgbus's custom RuboCop cops (Pgbus/NoRubyTimeout).
89
- # They're loaded by RuboCop via .rubocop.yml's `require:`, not by the
90
- # gem at runtime. Without this ignore, Zeitwerk scans lib/rubocop under
91
- # the pgbus root and tries to autoload a `Rubocop` constant, colliding
92
- # with the rubocop gem's real `RuboCop`.
93
- loader.ignore("#{__dir__}/rubocop")
94
116
  loader
95
117
  end
96
118
  end
@@ -187,6 +209,24 @@ module Pgbus
187
209
  Streams::Key.stream_key!(key)
188
210
  end
189
211
 
212
+ # Publish an event to the bus — the top-level shortcut for
213
+ # `Pgbus::EventBus::Publisher.publish`, symmetric with `Pgbus.stream`.
214
+ #
215
+ # Pgbus.publish("orders.created", { order_id: 42 })
216
+ # Pgbus.publish("orders.created", order, headers: { "x-trace" => id }, delay: 30)
217
+ def publish(routing_key, payload, headers: nil, delay: 0)
218
+ EventBus::Publisher.publish(routing_key, payload, headers: headers, delay: delay)
219
+ end
220
+
221
+ # Publish an event with a delay. Mirrors
222
+ # `Pgbus::EventBus::Publisher.publish_later`; `delay:` is required (a
223
+ # publish_later with no delay is just publish).
224
+ #
225
+ # Pgbus.publish_later("orders.reminder", { order_id: 42 }, delay: 1.hour)
226
+ def publish_later(routing_key, payload, delay:, headers: nil)
227
+ EventBus::Publisher.publish_later(routing_key, payload, delay: delay, headers: headers)
228
+ end
229
+
190
230
  def reset!
191
231
  @client&.close
192
232
  @client = nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.8
4
+ version: 0.9.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -150,7 +150,6 @@ files:
150
150
  - app/models/pgbus/application_record.rb
151
151
  - app/models/pgbus/batch_entry.rb
152
152
  - app/models/pgbus/blocked_execution.rb
153
- - app/models/pgbus/job_lock.rb
154
153
  - app/models/pgbus/job_stat.rb
155
154
  - app/models/pgbus/outbox_entry.rb
156
155
  - app/models/pgbus/process_entry.rb
@@ -209,7 +208,6 @@ files:
209
208
  - exe/pgbus
210
209
  - lib/active_job/queue_adapters/pgbus_adapter.rb
211
210
  - lib/generators/pgbus/add_failed_events_index_generator.rb
212
- - lib/generators/pgbus/add_job_locks_generator.rb
213
211
  - lib/generators/pgbus/add_job_stats_generator.rb
214
212
  - lib/generators/pgbus/add_job_stats_latency_generator.rb
215
213
  - lib/generators/pgbus/add_job_stats_queue_index_generator.rb
@@ -218,11 +216,11 @@ files:
218
216
  - lib/generators/pgbus/add_queue_states_generator.rb
219
217
  - lib/generators/pgbus/add_recurring_generator.rb
220
218
  - lib/generators/pgbus/add_stream_stats_generator.rb
219
+ - lib/generators/pgbus/add_uniqueness_keys_generator.rb
221
220
  - lib/generators/pgbus/install_generator.rb
222
221
  - lib/generators/pgbus/migrate_job_locks_generator.rb
223
222
  - lib/generators/pgbus/migration_path.rb
224
223
  - lib/generators/pgbus/templates/add_failed_events_unique_index.rb.erb
225
- - lib/generators/pgbus/templates/add_job_locks.rb.erb
226
224
  - lib/generators/pgbus/templates/add_job_stats.rb.erb
227
225
  - lib/generators/pgbus/templates/add_job_stats_latency.rb.erb
228
226
  - lib/generators/pgbus/templates/add_job_stats_queue_index.rb.erb
@@ -318,6 +316,7 @@ files:
318
316
  - lib/pgbus/outbox/poller.rb
319
317
  - lib/pgbus/pgmq_schema.rb
320
318
  - lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql
319
+ - lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql
321
320
  - lib/pgbus/process/consumer.rb
322
321
  - lib/pgbus/process/consumer_priority.rb
323
322
  - lib/pgbus/process/dispatcher.rb
@@ -383,8 +382,6 @@ files:
383
382
  - lib/pgbus/web/streamer/stream_counter.rb
384
383
  - lib/pgbus/web/streamer/stream_event_dispatcher.rb
385
384
  - lib/puma/plugin/pgbus_streams.rb
386
- - lib/rubocop/cop/pgbus/no_ruby_timeout.rb
387
- - lib/rubocop/pgbus.rb
388
385
  - lib/tasks/pgbus_autovacuum.rake
389
386
  - lib/tasks/pgbus_doctor.rake
390
387
  - lib/tasks/pgbus_pgmq.rake
@@ -397,6 +394,7 @@ metadata:
397
394
  homepage_uri: https://github.com/mhenrixon/pgbus
398
395
  source_code_uri: https://github.com/mhenrixon/pgbus/tree/main
399
396
  changelog_uri: https://github.com/mhenrixon/pgbus/blob/main/CHANGELOG.md
397
+ documentation_uri: https://pgbus.zoolutions.llc
400
398
  rubygems_mfa_required: 'true'
401
399
  rdoc_options: []
402
400
  require_paths:
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pgbus
4
- class JobLock < BusRecord
5
- self.table_name = "pgbus_job_locks"
6
-
7
- # States:
8
- # queued — lock held from enqueue time (:until_executed), no worker yet
9
- # executing — lock held by an active worker process
10
- STATES = %w[queued executing].freeze
11
-
12
- scope :executing, -> { where(state: "executing") }
13
- scope :queued_locks, -> { where(state: "queued") }
14
- scope :expired, ->(now = Time.current) { where("expires_at < ?", now) }
15
-
16
- # Atomically try to acquire a lock.
17
- # Cleans up expired locks for this key first (crash recovery at acquire time).
18
- # Returns true if acquired, false if already locked.
19
- #
20
- # Uses raw SQL on the hot path to minimize ActiveRecord allocations
21
- # (~29 objects vs ~304 per acquire+release cycle with AR query builder).
22
- def self.acquire!(lock_key, job_class:, ttl:, job_id: nil, state: "queued", owner_pid: nil, owner_hostname: nil) # rubocop:disable Naming/PredicateMethod
23
- expires_at = Time.current + ttl
24
-
25
- # Remove any expired lock for this key inline (last-resort TTL recovery)
26
- connection.exec_delete(
27
- "DELETE FROM #{table_name} WHERE lock_key = $1 AND expires_at < $2",
28
- "JobLock Expire", [lock_key, Time.current]
29
- )
30
-
31
- result = connection.exec_query(
32
- "INSERT INTO #{table_name} (lock_key, job_class, job_id, state, owner_pid, owner_hostname, expires_at) " \
33
- "VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (lock_key) DO NOTHING RETURNING id",
34
- "JobLock Acquire", [lock_key, job_class, job_id, state, owner_pid, owner_hostname, expires_at]
35
- )
36
- result.rows.any?
37
- end
38
-
39
- # Transition a queued lock to executing state and claim ownership.
40
- # Called when a worker starts executing a job that was locked at enqueue time.
41
- def self.claim_for_execution!(lock_key, owner_pid:, owner_hostname:, ttl:)
42
- connection.exec_update(
43
- "UPDATE #{table_name} SET state = $1, owner_pid = $2, owner_hostname = $3, expires_at = $4 " \
44
- "WHERE lock_key = $5",
45
- "JobLock Claim", ["executing", owner_pid, owner_hostname, Time.current + ttl, lock_key]
46
- )
47
- end
48
-
49
- # Release a lock by key.
50
- def self.release!(lock_key)
51
- connection.exec_delete(
52
- "DELETE FROM #{table_name} WHERE lock_key = $1",
53
- "JobLock Release", [lock_key]
54
- )
55
- end
56
-
57
- # Check if a lock is currently held (regardless of expiry — reaper handles orphans).
58
- def self.locked?(lock_key)
59
- result = connection.select_value(
60
- "SELECT 1 FROM #{table_name} WHERE lock_key = $1 LIMIT 1", "JobLock Check", [lock_key]
61
- )
62
- !result.nil?
63
- end
64
-
65
- # Reap orphaned locks whose owner is no longer alive, plus stale queued
66
- # locks that were never claimed by a worker.
67
- # Returns the total number of orphaned locks released.
68
- def self.reap_orphaned!
69
- reaped = 0
70
-
71
- # 1. Executing locks whose owner process has no healthy heartbeat
72
- alive_workers = ProcessEntry
73
- .where("last_heartbeat_at >= ?", Time.current - Process::Heartbeat::ALIVE_THRESHOLD)
74
- .pluck(:pid, :hostname)
75
-
76
- orphaned_executing = executing.select do |lock|
77
- alive_workers.none? { |pid, hostname| pid == lock.owner_pid && hostname == lock.owner_hostname }
78
- end
79
-
80
- reaped += where(id: orphaned_executing.map(&:id)).delete_all if orphaned_executing.any?
81
-
82
- # 2. Queued locks older than the visibility timeout that were never
83
- # claimed. These are left behind when enqueue fails after lock
84
- # acquisition (e.g. network error, process crash).
85
- threshold = Pgbus.configuration.visibility_timeout
86
- stale_queued = queued_locks.where("locked_at < ?", Time.current - threshold)
87
- reaped += stale_queued.delete_all if stale_queued.exists?
88
-
89
- reaped
90
- end
91
-
92
- # Last-resort cleanup: delete locks whose expires_at has passed.
93
- # This only fires when the reaper itself can't run (e.g., entire supervisor dead).
94
- def self.cleanup_expired!
95
- expired.delete_all
96
- end
97
- end
98
- end
@@ -1,21 +0,0 @@
1
- class AddPgbusJobLocks < ActiveRecord::Migration<%= migration_version %>
2
- def change
3
- create_table :pgbus_job_locks do |t|
4
- t.string :lock_key, null: false
5
- t.string :job_class, null: false
6
- t.string :job_id
7
- t.string :state, null: false, default: "queued"
8
- t.integer :owner_pid
9
- t.string :owner_hostname
10
- t.datetime :locked_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
11
- t.datetime :expires_at, null: false
12
- end
13
-
14
- add_index :pgbus_job_locks, :lock_key,
15
- unique: true, name: "idx_pgbus_job_locks_key"
16
- add_index :pgbus_job_locks, :expires_at,
17
- name: "idx_pgbus_job_locks_expires"
18
- add_index :pgbus_job_locks, [:state, :owner_pid],
19
- name: "idx_pgbus_job_locks_reaper"
20
- end
21
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RuboCop
4
- module Cop
5
- module Pgbus
6
- # Flags any use of Ruby's `Timeout.timeout`. It interrupts the target
7
- # thread with `Thread#raise`, which can fire at an arbitrary point —
8
- # including mid-libpq-call — and leave a pooled `PG::Connection` in a
9
- # corrupt state that re-hangs or returns wrong results on reuse. Prefer a
10
- # server-side/socket-level bound instead (Postgres `statement_timeout`,
11
- # libpq `tcp_user_timeout`/`keepalives`, an explicit `IO`/socket timeout).
12
- #
13
- # There is no autocorrect on purpose: the safe replacement depends on what
14
- # is being bounded, so a human must choose it.
15
- #
16
- # @example
17
- # # bad
18
- # Timeout.timeout(5) { do_work }
19
- #
20
- # # good — bound the actual resource (DB query, socket) directly
21
- # conn.exec("SET statement_timeout = 5000")
22
- # do_work
23
- class NoRubyTimeout < Base
24
- MSG = "Please be careful, Timeout is dangerous. Timeout.timeout uses " \
25
- "Thread#raise and can corrupt a pooled connection mid-call. Bound the " \
26
- "resource itself instead (statement_timeout / tcp_user_timeout / socket timeout)."
27
-
28
- # Matches `Timeout.timeout(...)` and `::Timeout.timeout(...)`.
29
- # @!method ruby_timeout?(node)
30
- def_node_matcher :ruby_timeout?, <<~PATTERN
31
- (send (const {nil? cbase} :Timeout) :timeout ...)
32
- PATTERN
33
-
34
- def on_send(node)
35
- return unless ruby_timeout?(node)
36
-
37
- add_offense(node.loc.selector)
38
- end
39
- end
40
- end
41
- end
42
- end
data/lib/rubocop/pgbus.rb DELETED
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Entry point for pgbus's custom RuboCop cops. Required from .rubocop.yml via
4
- # `require:` so the cops are available when linting this repo.
5
- require_relative "cop/pgbus/no_ruby_timeout"