pgbus 0.18.1 → 0.19.0

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +5 -0
  4. data/app/controllers/pgbus/application_controller.rb +2 -2
  5. data/app/controllers/pgbus/frontends_controller.rb +10 -8
  6. data/app/helpers/pgbus/application_helper.rb +4 -3
  7. data/app/models/pgbus/job_stat.rb +4 -0
  8. data/app/models/pgbus/processed_event.rb +3 -1
  9. data/app/models/pgbus/stream_queue.rb +34 -3
  10. data/app/models/pgbus/stream_stat.rb +3 -0
  11. data/lib/pgbus/active_job/current_attributes.rb +3 -1
  12. data/lib/pgbus/batch.rb +17 -9
  13. data/lib/pgbus/cli.rb +3 -0
  14. data/lib/pgbus/client/read_after.rb +10 -1
  15. data/lib/pgbus/client.rb +54 -9
  16. data/lib/pgbus/concurrency.rb +3 -0
  17. data/lib/pgbus/configuration.rb +84 -9
  18. data/lib/pgbus/current_attributes.rb +3 -1
  19. data/lib/pgbus/dedup_cache.rb +15 -7
  20. data/lib/pgbus/engine.rb +11 -0
  21. data/lib/pgbus/event_bus/handler.rb +16 -4
  22. data/lib/pgbus/execution_pools/async_pool.rb +23 -2
  23. data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
  24. data/lib/pgbus/execution_pools.rb +26 -0
  25. data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
  26. data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
  27. data/lib/pgbus/integrations/appsignal.rb +26 -16
  28. data/lib/pgbus/mcp/rack_app.rb +1 -1
  29. data/lib/pgbus/metrics/subscriber.rb +35 -21
  30. data/lib/pgbus/process/consumer.rb +1 -1
  31. data/lib/pgbus/process/consumer_priority.rb +1 -1
  32. data/lib/pgbus/process/lifecycle.rb +5 -5
  33. data/lib/pgbus/process/notify_listener.rb +32 -18
  34. data/lib/pgbus/process/wake_pipe.rb +4 -1
  35. data/lib/pgbus/process/worker.rb +4 -2
  36. data/lib/pgbus/retry_backoff.rb +3 -0
  37. data/lib/pgbus/shareable.rb +70 -0
  38. data/lib/pgbus/streams/broadcastable_override.rb +3 -0
  39. data/lib/pgbus/streams/presence.rb +6 -7
  40. data/lib/pgbus/streams.rb +38 -27
  41. data/lib/pgbus/testing.rb +19 -3
  42. data/lib/pgbus/uniqueness.rb +3 -0
  43. data/lib/pgbus/version.rb +1 -1
  44. data/lib/pgbus/visibility_heartbeat.rb +51 -34
  45. data/lib/pgbus/web/authentication.rb +19 -2
  46. data/lib/pgbus/web/data_source/list_counts.rb +8 -6
  47. data/lib/pgbus/web/health_server.rb +4 -1
  48. data/lib/pgbus/web/process_activity.rb +1 -1
  49. data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
  50. data/lib/pgbus/web/streamer/hub_client.rb +4 -1
  51. data/lib/pgbus/web/streamer/listener.rb +10 -1
  52. data/lib/pgbus/web/streamer/master_hub.rb +11 -5
  53. data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
  54. data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
  55. data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
  56. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
  57. data/lib/pgbus.rb +92 -30
  58. data/sig/pgbus/active_job/adapter.rbs +57 -0
  59. data/sig/pgbus/client.rbs +78 -0
  60. data/sig/pgbus/configuration.rbs +316 -0
  61. data/sig/pgbus/streams/stream.rbs +25 -0
  62. data/sig/pgbus/streams.rbs +22 -0
  63. data/sig/pgbus.rbs +73 -0
  64. metadata +8 -1
@@ -51,6 +51,12 @@ module Pgbus
51
51
  # wait, so recycling/deploy never wedges on a permanently-stuck job.
52
52
  attr_accessor :stall_threshold, :read_timeout, :drain_timeout
53
53
 
54
+ # immediate_shutdown_grace (default 2s): on QUIT, a worker or consumer
55
+ # stops taking work and gives the jobs already running this long to finish
56
+ # before it kills their threads. Killed jobs' ActiveRecord connections are
57
+ # reclaimed, so none stays checked out by a dead thread.
58
+ attr_accessor :immediate_shutdown_grace
59
+
54
60
  # shutdown_timeout bounds how long the supervisor waits for its children
55
61
  # after forwarding TERM before escalating to SIGKILL. nil (default) derives
56
62
  # drain_timeout + SHUTDOWN_TIMEOUT_MARGIN, so raising drain_timeout keeps
@@ -132,8 +138,11 @@ module Pgbus
132
138
  # Event bus
133
139
  attr_reader :idempotency_ttl
134
140
 
135
- # Logging
136
- attr_accessor :logger
141
+ # Logging. Unset means Rails.logger when Rails has one at the time of the
142
+ # read, else a $stdout Logger. Resolved on every read rather than when the
143
+ # configuration is built, because Pgbus builds it when the gem loads,
144
+ # which in a Rails app is before Rails.logger exists.
145
+ attr_writer :logger
137
146
  attr_reader :log_format # rubocop:disable Style/AccessorGrouping
138
147
 
139
148
  # Error reporting — array of callable objects invoked on caught exceptions.
@@ -296,6 +305,7 @@ module Pgbus
296
305
  @stall_threshold = 90
297
306
  @read_timeout = 30
298
307
  @drain_timeout = 30
308
+ @immediate_shutdown_grace = 2
299
309
  @shutdown_timeout = nil
300
310
 
301
311
  @dispatch_interval = 1.0
@@ -327,8 +337,10 @@ module Pgbus
327
337
  @idempotency_ttl = 7 * 24 * 3600 # 7 days
328
338
  @allowed_global_id_models = nil # nil = allow all (for backwards compat)
329
339
 
330
- @logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stdout)
340
+ @logger = nil
341
+ @stdout_logger = Logger.new($stdout)
331
342
  @log_format = :text
343
+ @log_format_set = false # see #apply_log_format!
332
344
  @error_reporters = []
333
345
 
334
346
  @listen_notify = true
@@ -566,6 +578,7 @@ module Pgbus
566
578
  @statsd_port = 8125
567
579
 
568
580
  @eager_validation = true
581
+ @finalized = false
569
582
  end
570
583
 
571
584
  def queue_name(name)
@@ -619,6 +632,10 @@ module Pgbus
619
632
  ExecutionPools.normalize_mode(mode)
620
633
  end
621
634
 
635
+ def logger
636
+ @logger || rails_logger || @stdout_logger
637
+ end
638
+
622
639
  VALID_LOG_FORMATS = %i[text json].freeze
623
640
 
624
641
  def log_format=(format)
@@ -628,15 +645,26 @@ module Pgbus
628
645
  end
629
646
 
630
647
  @log_format = format
648
+ @log_format_set = true
649
+ apply_log_format!
650
+ end
651
+
652
+ # Installs log_format's formatter on the logger pgbus resolves now; a
653
+ # no-op until log_format is set. The configuration exists before
654
+ # Rails.logger does, so a log_format set that early (config/application.rb)
655
+ # formats only the $stdout fallback: the engine calls this again once
656
+ # Rails.logger exists.
657
+ def apply_log_format!
658
+ return unless @log_format_set
631
659
 
632
660
  # Only install pgbus's formatter when the logger still carries a nil or
633
661
  # pgbus-installed formatter. A user who set a custom logger with a custom
634
662
  # formatter keeps it — log_format only records the intended format for
635
663
  # pgbus's own defaults, it must not silently clobber their formatting.
636
- old_formatter = @logger&.formatter
664
+ old_formatter = logger&.formatter
637
665
  return unless pgbus_installable_formatter?(old_formatter)
638
666
 
639
- new_formatter = case format
667
+ new_formatter = case log_format
640
668
  when :json then LogFormatter::JSON.new
641
669
  when :text then LogFormatter::Text.new
642
670
  end
@@ -649,7 +677,7 @@ module Pgbus
649
677
  # formatter. Re-extend the new formatter with the same module when the old
650
678
  # one carried it, so tagging keeps working (issue #334).
651
679
  preserve_tagged_logging(old_formatter, new_formatter)
652
- @logger.formatter = new_formatter
680
+ logger.formatter = new_formatter
653
681
  end
654
682
 
655
683
  VALID_BROADCAST_MODES = %i[ephemeral durable].freeze
@@ -799,6 +827,36 @@ module Pgbus
799
827
  @worker_notify_scope = coerced
800
828
  end
801
829
 
830
+ # Settings finalize! never freezes: @error_reporters is a list of
831
+ # callables apps append to (`c.error_reporters << ...`), and
832
+ # @deprecation_warned is this object's own bookkeeping.
833
+ FINALIZE_SKIP = %i[@error_reporters @deprecation_warned @finalized].freeze
834
+ private_constant :FINALIZE_SKIP
835
+
836
+ # Replaces every plain-data setting (Strings, Symbols, numbers, booleans,
837
+ # nil, Regexps, classes, and Arrays/Hashes/Sets/Ranges of those) with a
838
+ # deep-frozen, Ractor-shareable copy, so each reader hands back a value
839
+ # any Ractor can read (issue #527). Settings that hold a callable or an
840
+ # object (the logger, error reporters, hooks, the web data source, a
841
+ # metrics backend) are left as they are: they stay main-Ractor-only.
842
+ # Writers keep working (a setter assigns a new value; Pgbus.configure
843
+ # finalizes again); only in-place mutation of a frozen collection raises.
844
+ # Called once at boot by Pgbus.finalize!.
845
+ def finalize!
846
+ instance_variables.each do |ivar|
847
+ next if FINALIZE_SKIP.include?(ivar)
848
+
849
+ value = instance_variable_get(ivar)
850
+ instance_variable_set(ivar, Shareable.freeze_copy(value)) if Shareable.data?(value)
851
+ end
852
+ @finalized = true
853
+ self
854
+ end
855
+
856
+ def finalized?
857
+ @finalized
858
+ end
859
+
802
860
  def validate!
803
861
  if pool_size && !(pool_size.is_a?(Numeric) && pool_size.positive?)
804
862
  raise Pgbus::ConfigurationError, "pool_size must be a positive number or nil (auto-tune)"
@@ -829,6 +887,8 @@ module Pgbus
829
887
  end
830
888
  raise Pgbus::ConfigurationError, "drain_timeout must be > 0" unless drain_timeout.is_a?(Numeric) && drain_timeout.positive?
831
889
 
890
+ validate_immediate_shutdown_grace!
891
+
832
892
  validate_shutdown_timeout!
833
893
 
834
894
  unless stats_flush_size.is_a?(Integer) && stats_flush_size.positive?
@@ -1279,15 +1339,17 @@ module Pgbus
1279
1339
  validate_processes!(options[:processes]) unless options[:processes].nil?
1280
1340
 
1281
1341
  normalized_name = name.to_s
1282
- @workers ||= []
1342
+ existing = @workers || []
1283
1343
 
1284
- if @workers.any? { |c| capsule_name(c) == normalized_name }
1344
+ if existing.any? { |c| capsule_name(c) == normalized_name }
1285
1345
  raise Pgbus::ConfigurationError, "capsule #{name.inspect} is already defined"
1286
1346
  end
1287
1347
 
1288
1348
  validate_no_queue_overlap!(queues)
1289
1349
 
1290
- @workers << { name: normalized_name, queues: queues, threads: threads, **options }
1350
+ # A new list rather than <<: after finalize! @workers is frozen, and a
1351
+ # writer keeps working (a later Pgbus.configure finalizes again).
1352
+ @workers = [*existing, { name: normalized_name, queues: queues, threads: threads, **options }]
1291
1353
  end
1292
1354
 
1293
1355
  # Look up a capsule by its name. Accepts symbol or string. Returns the
@@ -1631,6 +1693,10 @@ module Pgbus
1631
1693
  end
1632
1694
  end
1633
1695
 
1696
+ def rails_logger
1697
+ ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
1698
+ end
1699
+
1634
1700
  # When the logger's previous formatter was extended with
1635
1701
  # ActiveSupport::TaggedLogging::Formatter (the case for a Rails
1636
1702
  # Rails.logger), extend the replacement pgbus formatter with the same module
@@ -1718,6 +1784,15 @@ module Pgbus
1718
1784
  "#{name} must be a Numeric (seconds), ActiveSupport::Duration, or nil to disable, got #{value.inspect}"
1719
1785
  end
1720
1786
 
1787
+ # A finite real number >= 0: NaN is not negative?, and an infinite grace
1788
+ # would never kill a job that does not finish.
1789
+ def validate_immediate_shutdown_grace!
1790
+ grace = immediate_shutdown_grace
1791
+ return if grace.is_a?(Numeric) && grace.real? && grace.finite? && grace >= 0
1792
+
1793
+ raise Pgbus::ConfigurationError, "immediate_shutdown_grace must be a finite number >= 0"
1794
+ end
1795
+
1721
1796
  def validate_positive_duration!(numeric, name)
1722
1797
  raise Pgbus::ConfigurationError, "#{name} must be a positive number, got #{numeric}" unless numeric.positive?
1723
1798
 
@@ -20,6 +20,8 @@ module Pgbus
20
20
  module CurrentAttributes
21
21
  METADATA_KEY = "pgbus_current"
22
22
 
23
+ # The names already warned about, so each missing class is logged once.
24
+ # Built at load; Concurrent::Map#compute_if_absent is atomic.
23
25
  @missing_warned = Concurrent::Map.new
24
26
 
25
27
  class << self
@@ -115,7 +117,7 @@ module Pgbus
115
117
  def resolve_class(name)
116
118
  name.constantize
117
119
  rescue NameError
118
- @missing_warned.compute_if_absent(name) do
120
+ @missing_warned.compute_if_absent(name) do # rubocop:disable ThreadSafety/ClassInstanceVariable -- see @missing_warned
119
121
  Pgbus.logger.warn { "[Pgbus] current_attributes: #{name} is not defined — skipping" }
120
122
  true
121
123
  end
@@ -17,6 +17,10 @@ module Pgbus
17
17
  @ttl = ttl
18
18
  @cache = Concurrent::Map.new
19
19
  @insertion_order = Concurrent::Array.new
20
+ # Serializes the check-then-act writers (mark!, expiry eviction, clear!)
21
+ # so the size bound and @insertion_order stay consistent (issue #527).
22
+ # seen? reads @cache without it.
23
+ @lock = Mutex.new
20
24
  end
21
25
 
22
26
  # Returns true if this key was already seen (duplicate).
@@ -26,7 +30,7 @@ module Pgbus
26
30
  return false unless entry
27
31
 
28
32
  if expired?(entry)
29
- evict(key)
33
+ @lock.synchronize { evict(key) if @cache[key].equal?(entry) }
30
34
  return false
31
35
  end
32
36
 
@@ -36,11 +40,13 @@ module Pgbus
36
40
  # Mark a key as seen. Call this after successfully claiming idempotency
37
41
  # in the database so future lookups skip the DB.
38
42
  def mark!(key)
39
- already_present = @cache.key?(key)
40
- evict_oldest if !already_present && @cache.size >= @max_size
43
+ @lock.synchronize do
44
+ already_present = @cache.key?(key)
45
+ evict_oldest if !already_present && @cache.size >= @max_size
41
46
 
42
- @cache[key] = monotonic_now
43
- @insertion_order << key unless already_present
47
+ @cache[key] = monotonic_now
48
+ @insertion_order << key unless already_present
49
+ end
44
50
  end
45
51
 
46
52
  def size
@@ -48,8 +54,10 @@ module Pgbus
48
54
  end
49
55
 
50
56
  def clear!
51
- @cache.clear
52
- @insertion_order.clear
57
+ @lock.synchronize do
58
+ @cache.clear
59
+ @insertion_order.clear
60
+ end
53
61
  end
54
62
 
55
63
  private
data/lib/pgbus/engine.rb CHANGED
@@ -81,6 +81,9 @@ module Pgbus
81
81
  end
82
82
 
83
83
  initializer "pgbus.logger" do
84
+ # Rails.logger exists now: a log_format set before it did went to the
85
+ # $stdout fallback only.
86
+ Pgbus.configuration.apply_log_format!
84
87
  ActiveSupport.on_load(:after_initialize) do
85
88
  Pgbus.configuration.logger ||= Rails.logger
86
89
  end
@@ -209,5 +212,13 @@ module Pgbus
209
212
  end
210
213
  end
211
214
  end
215
+
216
+ # Last: once the app's initializers have configured pgbus, freeze the
217
+ # configuration's plain-data settings into Ractor-shareable values
218
+ # (Pgbus.finalize!, issue #527). Registered after the app's initializer
219
+ # files, so it runs after the after_initialize blocks they add.
220
+ initializer "pgbus.finalize", after: :load_config_initializers do
221
+ ActiveSupport.on_load(:after_initialize) { Pgbus.finalize! }
222
+ end
212
223
  end
213
224
  end
@@ -4,6 +4,19 @@ module Pgbus
4
4
  module EventBus
5
5
  class Handler
6
6
  class << self
7
+ # One DedupCache per handler class, built when the class is defined
8
+ # (this class body, and #inherited for every subclass), never by a
9
+ # lazy `||=` that two first deliveries could each win (issue #527).
10
+ attr_reader :dedup_cache
11
+
12
+ def inherited(subclass)
13
+ super
14
+ subclass.instance_variable_set(:@dedup_cache, DedupCache.new)
15
+ end
16
+
17
+ # Class-body macro: `idempotent!` runs while the handler class loads,
18
+ # and #idempotent? only reads the flag afterwards.
19
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
7
20
  def idempotent!
8
21
  @idempotent = true
9
22
  end
@@ -11,10 +24,7 @@ module Pgbus
11
24
  def idempotent?
12
25
  @idempotent == true
13
26
  end
14
-
15
- def dedup_cache
16
- @dedup_cache ||= DedupCache.new
17
- end
27
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
18
28
 
19
29
  # How long a pending claim may stay silent before its holder counts as
20
30
  # dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
@@ -34,6 +44,8 @@ module Pgbus
34
44
  end
35
45
  end
36
46
 
47
+ @dedup_cache = DedupCache.new
48
+
37
49
  # Outcome of the two-phase claim. `age` is how long the losing delivery
38
50
  # found the existing claim to have been silent, in seconds (nil unless
39
51
  # the claim was pending).
@@ -3,6 +3,10 @@
3
3
  module Pgbus
4
4
  module ExecutionPools
5
5
  class AsyncPool
6
+ # How long #kill waits for the killed reactor thread (and the job
7
+ # fibers it cancels) to unwind before giving up on it.
8
+ KILL_JOIN_TIMEOUT = 1
9
+
6
10
  attr_reader :capacity
7
11
 
8
12
  def initialize(capacity:, on_state_change: nil)
@@ -70,9 +74,15 @@ module Pgbus
70
74
  @reactor_thread&.join(timeout)
71
75
  end
72
76
 
77
+ # Killing the reactor thread cancels its job fibers; wait for them to
78
+ # unwind so the caller can reclaim what they held (ExecutionPools.terminate).
73
79
  def kill
74
80
  shutdown
75
- @reactor_thread&.kill
81
+ thread = @reactor_thread
82
+ return unless thread
83
+
84
+ thread.kill
85
+ join_killed(thread)
76
86
  end
77
87
 
78
88
  def metadata
@@ -86,6 +96,14 @@ module Pgbus
86
96
 
87
97
  private
88
98
 
99
+ # The reactor's own failure is already recorded (register_fatal_error);
100
+ # Thread#join would raise it again here.
101
+ def join_killed(thread)
102
+ thread.join(KILL_JOIN_TIMEOUT)
103
+ rescue StandardError => e
104
+ Pgbus.logger.debug { "[Pgbus] Async pool reactor ended with #{e.class}: #{e.message}" }
105
+ end
106
+
89
107
  def validate_dependencies!
90
108
  require "async"
91
109
  require "async/semaphore"
@@ -95,9 +113,12 @@ module Pgbus
95
113
  "Add `gem \"async\"` to your Gemfile. Original error: #{e.message}"
96
114
  end
97
115
 
116
+ # The pool's one reactor thread, owned by this pool: started once by
117
+ # #initialize, it exits when #shutdown lets the reactor drain, and
118
+ # #wait_for_termination / #kill join it.
98
119
  # rubocop:disable-next Lint/RescueException
99
120
  def start_reactor
100
- Thread.new do
121
+ Thread.new do # rubocop:disable ThreadSafety/NewThread
101
122
  Thread.current.name = "pgbus-async-reactor-#{object_id}"
102
123
  Async do |task|
103
124
  semaphore = Async::Semaphore.new(@capacity, parent: task)
@@ -5,6 +5,10 @@ require "concurrent"
5
5
  module Pgbus
6
6
  module ExecutionPools
7
7
  class ThreadPool
8
+ # How long #kill waits, in all, for the killed jobs to unwind (run their
9
+ # ensure blocks) before giving up on those still running.
10
+ KILL_JOIN_TIMEOUT = 1
11
+
8
12
  attr_reader :capacity
9
13
 
10
14
  def initialize(capacity:, on_state_change: nil)
@@ -12,14 +16,17 @@ module Pgbus
12
16
  @on_state_change = on_state_change
13
17
  @available_capacity = Concurrent::AtomicFixnum.new(capacity)
14
18
  @pool = Concurrent::FixedThreadPool.new(capacity)
19
+ @running = Concurrent::Map.new
15
20
  end
16
21
 
17
22
  def post(&block)
18
23
  @available_capacity.decrement
19
24
  begin
20
25
  @pool.post do
26
+ @running[Thread.current] = true
21
27
  block.call
22
28
  ensure
29
+ @running.delete(Thread.current)
23
30
  @available_capacity.increment
24
31
  @on_state_change&.call
25
32
  end
@@ -52,8 +59,17 @@ module Pgbus
52
59
  @pool.wait_for_termination(timeout)
53
60
  end
54
61
 
62
+ # Thread#kill only asks a thread to stop: wait for the killed jobs to
63
+ # unwind, so the caller can reclaim what they held (ExecutionPools.terminate).
64
+ # The running jobs are listed after the kill: a job that started in
65
+ # between is killed too, and a job leaves the list only once it has
66
+ # unwound, so every one still unwinding is joined. One budget for all
67
+ # of them: stuck jobs must not hold a shutdown for capacity x the timeout.
55
68
  def kill
56
69
  @pool.kill
70
+ unwinding = @running.keys
71
+ deadline = monotonic_now + KILL_JOIN_TIMEOUT
72
+ unwinding.each { |thread| thread.join([deadline - monotonic_now, 0].max) }
57
73
  end
58
74
 
59
75
  def metadata
@@ -63,6 +79,12 @@ module Pgbus
63
79
  busy: @capacity - available_capacity
64
80
  }
65
81
  end
82
+
83
+ private
84
+
85
+ def monotonic_now
86
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
87
+ end
66
88
  end
67
89
  end
68
90
  end
@@ -22,6 +22,32 @@ module Pgbus
22
22
  raise ArgumentError, "Unknown execution_mode: #{mode.inspect}. Expected one of: :threads, :async, :fiber"
23
23
  end
24
24
  end
25
+
26
+ # Stop +pool+ taking work, give the jobs already running +grace+
27
+ # seconds to finish, and kill whatever is still running after that.
28
+ # Either way, reclaim the ActiveRecord connections the jobs left checked
29
+ # out: a killed thread or fiber never checks its own back in, and the
30
+ # pool would stay that many connections short until its reaper ran.
31
+ # Also when the wait raises (an async pool re-raises a fatal reactor
32
+ # error). Returns true when every job finished within the grace (issue #527).
33
+ def terminate(pool, grace:)
34
+ pool.shutdown
35
+ finished = pool.wait_for_termination(grace) ? true : false
36
+ pool.kill unless finished
37
+ finished
38
+ ensure
39
+ reclaim_connections
40
+ end
41
+
42
+ # The caller's own connection, then every connection whose owning
43
+ # thread or fiber is dead. A no-op without ActiveRecord.
44
+ def reclaim_connections
45
+ return unless defined?(::ActiveRecord::Base)
46
+
47
+ handler = ::ActiveRecord::Base.connection_handler
48
+ handler.clear_active_connections!(:all)
49
+ handler.connection_pool_list(:all).each(&:reap)
50
+ end
25
51
  end
26
52
  end
27
53
  end
@@ -24,23 +24,31 @@ module Pgbus
24
24
  METRIC_PREFIX = "pgbus_"
25
25
  private_constant :METRIC_PREFIX
26
26
 
27
+ # Registered once at boot; @install_lock makes the check-then-register
28
+ # atomic so the probe is never registered twice (issue #527).
29
+ @install_lock = Mutex.new
30
+ @installed = false
31
+
27
32
  class << self
28
- def install! # rubocop:disable Naming/PredicateMethod
29
- return false if @installed
33
+ def install!
34
+ @install_lock.synchronize do
35
+ next false if @installed
30
36
 
31
- ::Appsignal::Probes.register :pgbus, new_probe_instance
32
- @installed = true
33
- true
37
+ ::Appsignal::Probes.register :pgbus, new_probe_instance
38
+ @installed = true
39
+ end
34
40
  end
35
41
 
36
42
  def installed?
37
- @installed == true
43
+ @install_lock.synchronize { @installed }
38
44
  end
39
45
 
40
46
  def reset!
41
- ::Appsignal::Probes.unregister(:pgbus) if defined?(::Appsignal::Probes) &&
42
- ::Appsignal::Probes.respond_to?(:unregister)
43
- @installed = false
47
+ @install_lock.synchronize do
48
+ ::Appsignal::Probes.unregister(:pgbus) if defined?(::Appsignal::Probes) &&
49
+ ::Appsignal::Probes.respond_to?(:unregister)
50
+ @installed = false
51
+ end
44
52
  end
45
53
 
46
54
  # Visible for testing — returns a fresh runnable probe.
@@ -21,14 +21,39 @@ module Pgbus
21
21
  METRIC_PREFIX = "pgbus_"
22
22
  private_constant :BACKGROUND_JOB, :METRIC_PREFIX
23
23
 
24
- # Tracked so we can detach in reset! (used by specs).
25
- @subscriptions = []
24
+ # Tracked so we can detach in reset! (used by specs). Installed once
25
+ # at boot (the engine's after_initialize); @install_lock makes the
26
+ # check-then-install atomic so nothing subscribes twice (issue #527).
27
+ @install_lock = Mutex.new
28
+ @subscriptions = [].freeze
29
+ @installed = false
26
30
 
27
31
  class << self
28
32
  def install!
29
- return false if @installed
33
+ @install_lock.synchronize do
34
+ next false if @installed
30
35
 
31
- @subscriptions = [
36
+ @subscriptions = subscribe_all
37
+ @installed = true
38
+ end
39
+ end
40
+
41
+ def installed?
42
+ @install_lock.synchronize { @installed }
43
+ end
44
+
45
+ def reset!
46
+ @install_lock.synchronize do
47
+ @subscriptions.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
48
+ @subscriptions = [].freeze
49
+ @installed = false
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def subscribe_all
56
+ [
32
57
  subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
33
58
  subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
34
59
  subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
@@ -48,22 +73,9 @@ module Pgbus
48
73
  # pgbus_pool_available gauges for AppSignal (see probe.rb).
49
74
  # Subscribing here too would double-report on every heartbeat
50
75
  # and skew the gauges.
51
- ]
52
- @installed = true
53
- end
54
-
55
- def installed?
56
- @installed == true
76
+ ].freeze
57
77
  end
58
78
 
59
- def reset!
60
- @subscriptions&.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
61
- @subscriptions = []
62
- @installed = false
63
- end
64
-
65
- private
66
-
67
79
  def subscribe(name, &block)
68
80
  # silence rubocop unused
69
81
  ActiveSupport::Notifications.subscribe(name) do |*args|
@@ -28,41 +28,51 @@ module Pgbus
28
28
  DASHBOARD_PATH = File.expand_path("appsignal/dashboard.json", __dir__).freeze
29
29
  DASHBOARDS_DIR = File.expand_path("appsignal/dashboards", __dir__).freeze
30
30
 
31
+ # Installed once at boot (the engine's after_initialize); @lock makes
32
+ # the check-then-install atomic and guards the parsed-dashboard memos
33
+ # (issue #527).
34
+ @lock = Mutex.new
35
+ @installed = false
36
+
31
37
  module_function
32
38
 
33
- def install! # rubocop:disable Naming/PredicateMethod
39
+ def install!
34
40
  return false unless defined?(::Appsignal)
35
- return false if @installed
36
41
 
37
- Subscriber.install!
38
- Probe.install! if Pgbus.configuration.appsignal_probe_enabled
39
- @installed = true
40
- Pgbus.logger.info { "[Pgbus] AppSignal integration installed" }
41
- true
42
+ installed_now = @lock.synchronize do
43
+ next false if @installed
44
+
45
+ Subscriber.install!
46
+ Probe.install! if Pgbus.configuration.appsignal_probe_enabled
47
+ @installed = true
48
+ end
49
+ Pgbus.logger.info { "[Pgbus] AppSignal integration installed" } if installed_now
50
+ installed_now
42
51
  end
43
52
 
44
53
  def installed?
45
- @installed == true
54
+ @lock.synchronize { @installed }
46
55
  end
47
56
 
48
57
  def dashboard_definition
49
- @dashboard_definition ||= JSON.parse(File.read(DASHBOARD_PATH))
58
+ @lock.synchronize { @dashboard_definition ||= JSON.parse(File.read(DASHBOARD_PATH)) }
50
59
  end
51
60
 
52
61
  def dashboard_definitions
53
- @dashboard_definitions ||=
54
- Dir[File.join(DASHBOARDS_DIR, "*.json")].map do |path|
55
- JSON.parse(File.read(path))
56
- end
62
+ @lock.synchronize do
63
+ @dashboard_definitions ||= Dir[File.join(DASHBOARDS_DIR, "*.json")].map { |path| JSON.parse(File.read(path)) }
64
+ end
57
65
  end
58
66
 
59
67
  # Test hook: tear everything down so a fresh install! can run.
60
68
  def reset!
61
69
  Subscriber.reset! if defined?(Subscriber)
62
70
  Probe.reset! if defined?(Probe)
63
- @installed = false
64
- @dashboard_definition = nil
65
- @dashboard_definitions = nil
71
+ @lock.synchronize do
72
+ @installed = false
73
+ @dashboard_definition = nil
74
+ @dashboard_definitions = nil
75
+ end
66
76
  end
67
77
  end
68
78
  end
@@ -37,7 +37,7 @@ module Pgbus
37
37
  # First mcp release with the transport's allowed_hosts / allowed_origins /
38
38
  # dns_rebinding_protection options. Older gems would raise ArgumentError
39
39
  # on the pass-through; fail with the fix spelled out instead.
40
- MIN_MCP_VERSION = Gem::Version.new("0.23.0")
40
+ MIN_MCP_VERSION = Gem::Version.new("0.23.0").freeze
41
41
  # Only the JSON body string is frozen and reused. The outer response triple
42
42
  # and its headers hash MUST be built fresh per call (#unauthorized) so
43
43
  # downstream Rack middleware can mutate them — Rack::TempfileReaper assigns