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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +5 -0
- data/app/controllers/pgbus/application_controller.rb +2 -2
- data/app/controllers/pgbus/frontends_controller.rb +10 -8
- data/app/helpers/pgbus/application_helper.rb +4 -3
- data/app/models/pgbus/job_stat.rb +4 -0
- data/app/models/pgbus/processed_event.rb +3 -1
- data/app/models/pgbus/stream_queue.rb +34 -3
- data/app/models/pgbus/stream_stat.rb +3 -0
- data/lib/pgbus/active_job/current_attributes.rb +3 -1
- data/lib/pgbus/batch.rb +17 -9
- data/lib/pgbus/cli.rb +3 -0
- data/lib/pgbus/client/read_after.rb +10 -1
- data/lib/pgbus/client.rb +54 -9
- data/lib/pgbus/concurrency.rb +3 -0
- data/lib/pgbus/configuration.rb +84 -9
- data/lib/pgbus/current_attributes.rb +3 -1
- data/lib/pgbus/dedup_cache.rb +15 -7
- data/lib/pgbus/engine.rb +11 -0
- data/lib/pgbus/event_bus/handler.rb +16 -4
- data/lib/pgbus/execution_pools/async_pool.rb +23 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
- data/lib/pgbus/execution_pools.rb +26 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
- data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
- data/lib/pgbus/integrations/appsignal.rb +26 -16
- data/lib/pgbus/mcp/rack_app.rb +1 -1
- data/lib/pgbus/metrics/subscriber.rb +35 -21
- data/lib/pgbus/process/consumer.rb +1 -1
- data/lib/pgbus/process/consumer_priority.rb +1 -1
- data/lib/pgbus/process/lifecycle.rb +5 -5
- data/lib/pgbus/process/notify_listener.rb +32 -18
- data/lib/pgbus/process/wake_pipe.rb +4 -1
- data/lib/pgbus/process/worker.rb +4 -2
- data/lib/pgbus/retry_backoff.rb +3 -0
- data/lib/pgbus/shareable.rb +70 -0
- data/lib/pgbus/streams/broadcastable_override.rb +3 -0
- data/lib/pgbus/streams/presence.rb +6 -7
- data/lib/pgbus/streams.rb +38 -27
- data/lib/pgbus/testing.rb +19 -3
- data/lib/pgbus/uniqueness.rb +3 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +51 -34
- data/lib/pgbus/web/authentication.rb +19 -2
- data/lib/pgbus/web/data_source/list_counts.rb +8 -6
- data/lib/pgbus/web/health_server.rb +4 -1
- data/lib/pgbus/web/process_activity.rb +1 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
- data/lib/pgbus/web/streamer/hub_client.rb +4 -1
- data/lib/pgbus/web/streamer/listener.rb +10 -1
- data/lib/pgbus/web/streamer/master_hub.rb +11 -5
- data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
- data/lib/pgbus.rb +92 -30
- data/sig/pgbus/active_job/adapter.rbs +57 -0
- data/sig/pgbus/client.rbs +78 -0
- data/sig/pgbus/configuration.rbs +316 -0
- data/sig/pgbus/streams/stream.rbs +25 -0
- data/sig/pgbus/streams.rbs +22 -0
- data/sig/pgbus.rbs +73 -0
- metadata +8 -1
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
664
|
+
old_formatter = logger&.formatter
|
|
637
665
|
return unless pgbus_installable_formatter?(old_formatter)
|
|
638
666
|
|
|
639
|
-
new_formatter = case
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/pgbus/dedup_cache.rb
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
43
|
+
@lock.synchronize do
|
|
44
|
+
already_present = @cache.key?(key)
|
|
45
|
+
evict_oldest if !already_present && @cache.size >= @max_size
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
@
|
|
52
|
-
|
|
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
|
|
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!
|
|
29
|
-
|
|
33
|
+
def install!
|
|
34
|
+
@install_lock.synchronize do
|
|
35
|
+
next false if @installed
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
::Appsignal::Probes.register :pgbus, new_probe_instance
|
|
38
|
+
@installed = true
|
|
39
|
+
end
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def installed?
|
|
37
|
-
@installed
|
|
43
|
+
@install_lock.synchronize { @installed }
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def reset!
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
33
|
+
@install_lock.synchronize do
|
|
34
|
+
next false if @installed
|
|
30
35
|
|
|
31
|
-
|
|
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!
|
|
39
|
+
def install!
|
|
34
40
|
return false unless defined?(::Appsignal)
|
|
35
|
-
return false if @installed
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
@
|
|
54
|
-
Dir[File.join(DASHBOARDS_DIR, "*.json")].map
|
|
55
|
-
|
|
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
|
-
@
|
|
64
|
-
|
|
65
|
-
|
|
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
|
data/lib/pgbus/mcp/rack_app.rb
CHANGED
|
@@ -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
|