pgbus 0.18.2 → 0.19.1
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 +345 -142
- data/README.md +49 -0
- data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
- data/app/controllers/pgbus/frontends_controller.rb +10 -8
- data/app/helpers/pgbus/application_helper.rb +4 -3
- data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
- data/app/helpers/pgbus/processes_helper.rb +1 -1
- data/app/models/pgbus/deploy_task.rb +104 -0
- data/app/models/pgbus/deploy_task_run.rb +45 -0
- 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/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
- data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
- data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
- data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
- data/config/i18n-tasks.yml +2 -0
- data/config/locales/da.yml +97 -0
- data/config/locales/de.yml +97 -0
- data/config/locales/en.yml +97 -0
- data/config/locales/es.yml +97 -0
- data/config/locales/fi.yml +97 -0
- data/config/locales/fr.yml +97 -0
- data/config/locales/it.yml +97 -0
- data/config/locales/ja.yml +97 -0
- data/config/locales/nb.yml +97 -0
- data/config/locales/nl.yml +97 -0
- data/config/locales/pt.yml +97 -0
- data/config/locales/sv.yml +97 -0
- data/config/routes.rb +7 -0
- data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
- data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
- data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +43 -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/deploy_settings.rb +46 -0
- data/lib/pgbus/configuration.rb +87 -9
- data/lib/pgbus/current_attributes.rb +3 -1
- data/lib/pgbus/dedup_cache.rb +15 -7
- data/lib/pgbus/deploy/revision.rb +59 -0
- data/lib/pgbus/deploy/rollover.rb +65 -0
- data/lib/pgbus/deploy/runner.rb +259 -0
- data/lib/pgbus/deploy/task.rb +105 -0
- data/lib/pgbus/engine.rb +28 -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/generators/migration_detector.rb +11 -0
- data/lib/pgbus/instrumentation.rb +3 -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/mcp/server.rb +2 -1
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
- data/lib/pgbus/mcp.rb +1 -0
- data/lib/pgbus/metrics/subscriber.rb +35 -21
- data/lib/pgbus/outbox/poller.rb +1 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/consumer_priority.rb +1 -1
- data/lib/pgbus/process/dispatcher.rb +18 -1
- data/lib/pgbus/process/heartbeat.rb +7 -2
- data/lib/pgbus/process/lifecycle.rb +5 -5
- data/lib/pgbus/process/notify_listener.rb +32 -18
- data/lib/pgbus/process/supervisor.rb +35 -3
- data/lib/pgbus/process/wake_pipe.rb +4 -1
- data/lib/pgbus/process/worker.rb +5 -2
- data/lib/pgbus/recurring/scheduler.rb +54 -20
- 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/deploy_task_list.rb +114 -0
- data/lib/pgbus/web/data_source/list_counts.rb +10 -7
- data/lib/pgbus/web/data_source.rb +1 -0
- data/lib/pgbus/web/deploy_task_state.rb +51 -0
- 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 +326 -0
- data/sig/pgbus/streams/stream.rbs +25 -0
- data/sig/pgbus/streams.rbs +22 -0
- data/sig/pgbus.rbs +73 -0
- metadata +27 -1
data/lib/pgbus/client.rb
CHANGED
|
@@ -20,9 +20,6 @@ module Pgbus
|
|
|
20
20
|
|
|
21
21
|
attr_reader :pgmq, :config, :connection_health
|
|
22
22
|
|
|
23
|
-
PGMQ_REQUIRE_MUTEX = Mutex.new
|
|
24
|
-
private_constant :PGMQ_REQUIRE_MUTEX
|
|
25
|
-
|
|
26
23
|
# Fixed advisory-lock key serializing pgmq schema installation across
|
|
27
24
|
# processes (issue #397). "pgmqinst" in ASCII hex — arbitrary but stable;
|
|
28
25
|
# it only has to be identical in every process that can install.
|
|
@@ -55,6 +52,11 @@ module Pgbus
|
|
|
55
52
|
# (issue #397, forensics in getzazu/app#3413).
|
|
56
53
|
@pgmq_install_mutex = Mutex.new
|
|
57
54
|
|
|
55
|
+
# Serializes the one-time `require "pgmq"` (see load_pgmq_gem!). Class
|
|
56
|
+
# state built at load, not a constant: a constant holding a Mutex is
|
|
57
|
+
# unreadable from a non-main Ractor (issue #527).
|
|
58
|
+
@pgmq_require_mutex = Mutex.new
|
|
59
|
+
|
|
58
60
|
class << self
|
|
59
61
|
attr_reader :pgmq_install_mutex
|
|
60
62
|
end
|
|
@@ -82,7 +84,10 @@ module Pgbus
|
|
|
82
84
|
# instead of the global Kernel#require, which — if stubbed before pgmq is
|
|
83
85
|
# genuinely loaded — permanently prevents the real gem from ever loading.
|
|
84
86
|
def self.load_pgmq_gem!
|
|
85
|
-
|
|
87
|
+
# The lock is Client's class state, which a subclass does not inherit.
|
|
88
|
+
return Client.load_pgmq_gem! unless equal?(Client)
|
|
89
|
+
|
|
90
|
+
@pgmq_require_mutex.synchronize do
|
|
86
91
|
Object.const_set(:PGMQ, Module.new) unless defined?(::PGMQ)
|
|
87
92
|
require "pgmq"
|
|
88
93
|
end
|
|
@@ -920,10 +925,33 @@ module Pgbus
|
|
|
920
925
|
|
|
921
926
|
# --- LISTEN/NOTIFY management (PGMQ v1.11.0+) ---
|
|
922
927
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
928
|
+
# Blocks until PGMQ's insert trigger notifies +queue_name+ or +timeout+
|
|
929
|
+
# seconds pass (nil waits indefinitely); returns the channel, or nil on a
|
|
930
|
+
# timeout, and yields (channel, pid, payload) like
|
|
931
|
+
# PG::Connection#wait_for_notify. The wait gets a dedicated connection of
|
|
932
|
+
# its own (worker_notify_connection_options), opened for it and closed
|
|
933
|
+
# after: never a pool connection, and never under @pgmq_mutex, which on
|
|
934
|
+
# the shared-AR path serializes every PGMQ call, so holding it across a
|
|
935
|
+
# blocking wait stalled the whole client (issue #527). The shared-AR path
|
|
936
|
+
# therefore needs worker_notify_database_url (or a base database_url).
|
|
937
|
+
# A connection found dead before it was used (STALE_CONNECTION_PATTERNS)
|
|
938
|
+
# is replaced and the wait starts again, as with_stale_connection_retry
|
|
939
|
+
# did for the pooled wait; a connection lost mid-wait is not retried.
|
|
940
|
+
def wait_for_notify(queue_name, timeout: nil, &)
|
|
941
|
+
channel = "pgmq.q_#{config.queue_name(queue_name)}.INSERT"
|
|
942
|
+
attempts = 0
|
|
943
|
+
begin
|
|
944
|
+
listen_on_dedicated_connection(channel, timeout, &)
|
|
945
|
+
rescue StandardError => e
|
|
946
|
+
attempts += 1
|
|
947
|
+
raise unless attempts <= STALE_RETRY_ATTEMPTS && stale_pg_error?(e)
|
|
948
|
+
|
|
949
|
+
sleep STALE_RETRY_DELAYS[[attempts - 1, STALE_RETRY_DELAYS.size - 1].min]
|
|
950
|
+
Pgbus.logger.warn do
|
|
951
|
+
"[Pgbus::Client] Retrying wait_for_notify after a stale connection " \
|
|
952
|
+
"(attempt #{attempts}/#{STALE_RETRY_ATTEMPTS}): #{e.message}"
|
|
953
|
+
end
|
|
954
|
+
retry
|
|
927
955
|
end
|
|
928
956
|
end
|
|
929
957
|
|
|
@@ -1199,7 +1227,8 @@ module Pgbus
|
|
|
1199
1227
|
def ensure_pgmq_schema
|
|
1200
1228
|
return if @schema_ensured
|
|
1201
1229
|
|
|
1202
|
-
|
|
1230
|
+
# Client's lock, also for a subclass: it serializes every client's install.
|
|
1231
|
+
Client.pgmq_install_mutex.synchronize do
|
|
1203
1232
|
return if @schema_ensured
|
|
1204
1233
|
|
|
1205
1234
|
# Cache only a durable result: true only when this call owned the
|
|
@@ -2069,6 +2098,22 @@ module Pgbus
|
|
|
2069
2098
|
end
|
|
2070
2099
|
end
|
|
2071
2100
|
|
|
2101
|
+
def listen_on_dedicated_connection(channel, timeout, &)
|
|
2102
|
+
conn = DedicatedConnection.connect(config.worker_notify_connection_options)
|
|
2103
|
+
begin
|
|
2104
|
+
conn.exec("LISTEN #{conn.quote_ident(channel)}")
|
|
2105
|
+
conn.wait_for_notify(timeout, &)
|
|
2106
|
+
ensure
|
|
2107
|
+
DedicatedConnection.close_quietly(conn)
|
|
2108
|
+
end
|
|
2109
|
+
end
|
|
2110
|
+
|
|
2111
|
+
# A raw PG::Error (the dedicated connection is not wrapped by pgmq-ruby)
|
|
2112
|
+
# whose message says the connection was dead before it was used.
|
|
2113
|
+
def stale_pg_error?(error)
|
|
2114
|
+
defined?(::PG::Error) && error.is_a?(::PG::Error) && stale_connection_error?(error)
|
|
2115
|
+
end
|
|
2116
|
+
|
|
2072
2117
|
def stale_connection_error?(error)
|
|
2073
2118
|
# Substring match against any stale-connection pattern. Deliberately a
|
|
2074
2119
|
# single Regexp rather than `STALE_CONNECTION_PATTERNS.any? { |p| msg.include?(p) }`:
|
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -12,6 +12,9 @@ module Pgbus
|
|
|
12
12
|
# parked job whose class no longer resolves.
|
|
13
13
|
DEFAULT_DURATION = 15 * 60
|
|
14
14
|
|
|
15
|
+
# Class-body macro: it runs while the job class loads (at boot, or under the
|
|
16
|
+
# autoloader's lock), stores a frozen Hash, and the reader only reads it.
|
|
17
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
15
18
|
class_methods do
|
|
16
19
|
# Limit concurrent execution of jobs with the same key.
|
|
17
20
|
#
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Configuration
|
|
5
|
+
# Deploy tasks (issue #536): one-time jobs the scheduler enqueues once
|
|
6
|
+
# every live pgbus process runs the new app revision.
|
|
7
|
+
#
|
|
8
|
+
# config.app_revision = -> { File.read("REVISION").strip } # or a String
|
|
9
|
+
# config.deploy_tasks_files = ["config/deploy_tasks.yml"]
|
|
10
|
+
# config.deploy_task_run_retention = 30.days
|
|
11
|
+
module DeploySettings
|
|
12
|
+
DEFAULT_DEPLOY_TASK_RUN_RETENTION = 90 * 24 * 3600
|
|
13
|
+
|
|
14
|
+
attr_accessor :app_revision, :deploy_tasks, :deploy_tasks_files, :deploy_tasks_enabled
|
|
15
|
+
attr_reader :deploy_task_run_retention
|
|
16
|
+
|
|
17
|
+
def deploy_task_run_retention=(value)
|
|
18
|
+
@deploy_task_run_retention = coerce_duration!(value, :deploy_task_run_retention)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def initialize_deploy_settings
|
|
24
|
+
@app_revision = nil
|
|
25
|
+
@deploy_tasks = nil
|
|
26
|
+
@deploy_tasks_files = nil
|
|
27
|
+
@deploy_tasks_enabled = true
|
|
28
|
+
@deploy_task_run_retention = DEFAULT_DEPLOY_TASK_RUN_RETENTION
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_deploy_settings!
|
|
32
|
+
unless app_revision.nil? || app_revision.is_a?(String) || app_revision.respond_to?(:call)
|
|
33
|
+
raise Pgbus::ConfigurationError, "app_revision must be a String, respond to #call, or be nil"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise Pgbus::ConfigurationError, "deploy_tasks must be a Hash or nil" unless deploy_tasks.nil? || deploy_tasks.is_a?(Hash)
|
|
37
|
+
unless deploy_tasks_files.nil? || deploy_tasks_files.is_a?(Array)
|
|
38
|
+
raise Pgbus::ConfigurationError, "deploy_tasks_files must be an Array of paths or nil"
|
|
39
|
+
end
|
|
40
|
+
return if [true, false].include?(deploy_tasks_enabled)
|
|
41
|
+
|
|
42
|
+
raise Pgbus::ConfigurationError, "deploy_tasks_enabled must be true or false"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Pgbus
|
|
|
6
6
|
class Configuration
|
|
7
7
|
include PresenceSettings
|
|
8
8
|
include StreamAuthorization
|
|
9
|
+
include DeploySettings
|
|
9
10
|
|
|
10
11
|
# Connection settings
|
|
11
12
|
attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
|
|
@@ -51,6 +52,12 @@ module Pgbus
|
|
|
51
52
|
# wait, so recycling/deploy never wedges on a permanently-stuck job.
|
|
52
53
|
attr_accessor :stall_threshold, :read_timeout, :drain_timeout
|
|
53
54
|
|
|
55
|
+
# immediate_shutdown_grace (default 2s): on QUIT, a worker or consumer
|
|
56
|
+
# stops taking work and gives the jobs already running this long to finish
|
|
57
|
+
# before it kills their threads. Killed jobs' ActiveRecord connections are
|
|
58
|
+
# reclaimed, so none stays checked out by a dead thread.
|
|
59
|
+
attr_accessor :immediate_shutdown_grace
|
|
60
|
+
|
|
54
61
|
# shutdown_timeout bounds how long the supervisor waits for its children
|
|
55
62
|
# after forwarding TERM before escalating to SIGKILL. nil (default) derives
|
|
56
63
|
# drain_timeout + SHUTDOWN_TIMEOUT_MARGIN, so raising drain_timeout keeps
|
|
@@ -132,8 +139,11 @@ module Pgbus
|
|
|
132
139
|
# Event bus
|
|
133
140
|
attr_reader :idempotency_ttl
|
|
134
141
|
|
|
135
|
-
# Logging
|
|
136
|
-
|
|
142
|
+
# Logging. Unset means Rails.logger when Rails has one at the time of the
|
|
143
|
+
# read, else a $stdout Logger. Resolved on every read rather than when the
|
|
144
|
+
# configuration is built, because Pgbus builds it when the gem loads,
|
|
145
|
+
# which in a Rails app is before Rails.logger exists.
|
|
146
|
+
attr_writer :logger
|
|
137
147
|
attr_reader :log_format # rubocop:disable Style/AccessorGrouping
|
|
138
148
|
|
|
139
149
|
# Error reporting — array of callable objects invoked on caught exceptions.
|
|
@@ -296,6 +306,7 @@ module Pgbus
|
|
|
296
306
|
@stall_threshold = 90
|
|
297
307
|
@read_timeout = 30
|
|
298
308
|
@drain_timeout = 30
|
|
309
|
+
@immediate_shutdown_grace = 2
|
|
299
310
|
@shutdown_timeout = nil
|
|
300
311
|
|
|
301
312
|
@dispatch_interval = 1.0
|
|
@@ -327,8 +338,10 @@ module Pgbus
|
|
|
327
338
|
@idempotency_ttl = 7 * 24 * 3600 # 7 days
|
|
328
339
|
@allowed_global_id_models = nil # nil = allow all (for backwards compat)
|
|
329
340
|
|
|
330
|
-
@logger =
|
|
341
|
+
@logger = nil
|
|
342
|
+
@stdout_logger = Logger.new($stdout)
|
|
331
343
|
@log_format = :text
|
|
344
|
+
@log_format_set = false # see #apply_log_format!
|
|
332
345
|
@error_reporters = []
|
|
333
346
|
|
|
334
347
|
@listen_notify = true
|
|
@@ -351,6 +364,7 @@ module Pgbus
|
|
|
351
364
|
@recurring_tasks_files = nil
|
|
352
365
|
@recurring_enabled = true
|
|
353
366
|
@recurring_execution_retention = 7 * 24 * 3600 # 7 days
|
|
367
|
+
initialize_deploy_settings
|
|
354
368
|
|
|
355
369
|
@zombie_detection = true
|
|
356
370
|
|
|
@@ -566,6 +580,7 @@ module Pgbus
|
|
|
566
580
|
@statsd_port = 8125
|
|
567
581
|
|
|
568
582
|
@eager_validation = true
|
|
583
|
+
@finalized = false
|
|
569
584
|
end
|
|
570
585
|
|
|
571
586
|
def queue_name(name)
|
|
@@ -619,6 +634,10 @@ module Pgbus
|
|
|
619
634
|
ExecutionPools.normalize_mode(mode)
|
|
620
635
|
end
|
|
621
636
|
|
|
637
|
+
def logger
|
|
638
|
+
@logger || rails_logger || @stdout_logger
|
|
639
|
+
end
|
|
640
|
+
|
|
622
641
|
VALID_LOG_FORMATS = %i[text json].freeze
|
|
623
642
|
|
|
624
643
|
def log_format=(format)
|
|
@@ -628,15 +647,26 @@ module Pgbus
|
|
|
628
647
|
end
|
|
629
648
|
|
|
630
649
|
@log_format = format
|
|
650
|
+
@log_format_set = true
|
|
651
|
+
apply_log_format!
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Installs log_format's formatter on the logger pgbus resolves now; a
|
|
655
|
+
# no-op until log_format is set. The configuration exists before
|
|
656
|
+
# Rails.logger does, so a log_format set that early (config/application.rb)
|
|
657
|
+
# formats only the $stdout fallback: the engine calls this again once
|
|
658
|
+
# Rails.logger exists.
|
|
659
|
+
def apply_log_format!
|
|
660
|
+
return unless @log_format_set
|
|
631
661
|
|
|
632
662
|
# Only install pgbus's formatter when the logger still carries a nil or
|
|
633
663
|
# pgbus-installed formatter. A user who set a custom logger with a custom
|
|
634
664
|
# formatter keeps it — log_format only records the intended format for
|
|
635
665
|
# pgbus's own defaults, it must not silently clobber their formatting.
|
|
636
|
-
old_formatter =
|
|
666
|
+
old_formatter = logger&.formatter
|
|
637
667
|
return unless pgbus_installable_formatter?(old_formatter)
|
|
638
668
|
|
|
639
|
-
new_formatter = case
|
|
669
|
+
new_formatter = case log_format
|
|
640
670
|
when :json then LogFormatter::JSON.new
|
|
641
671
|
when :text then LogFormatter::Text.new
|
|
642
672
|
end
|
|
@@ -649,7 +679,7 @@ module Pgbus
|
|
|
649
679
|
# formatter. Re-extend the new formatter with the same module when the old
|
|
650
680
|
# one carried it, so tagging keeps working (issue #334).
|
|
651
681
|
preserve_tagged_logging(old_formatter, new_formatter)
|
|
652
|
-
|
|
682
|
+
logger.formatter = new_formatter
|
|
653
683
|
end
|
|
654
684
|
|
|
655
685
|
VALID_BROADCAST_MODES = %i[ephemeral durable].freeze
|
|
@@ -799,6 +829,36 @@ module Pgbus
|
|
|
799
829
|
@worker_notify_scope = coerced
|
|
800
830
|
end
|
|
801
831
|
|
|
832
|
+
# Settings finalize! never freezes: @error_reporters is a list of
|
|
833
|
+
# callables apps append to (`c.error_reporters << ...`), and
|
|
834
|
+
# @deprecation_warned is this object's own bookkeeping.
|
|
835
|
+
FINALIZE_SKIP = %i[@error_reporters @deprecation_warned @finalized].freeze
|
|
836
|
+
private_constant :FINALIZE_SKIP
|
|
837
|
+
|
|
838
|
+
# Replaces every plain-data setting (Strings, Symbols, numbers, booleans,
|
|
839
|
+
# nil, Regexps, classes, and Arrays/Hashes/Sets/Ranges of those) with a
|
|
840
|
+
# deep-frozen, Ractor-shareable copy, so each reader hands back a value
|
|
841
|
+
# any Ractor can read (issue #527). Settings that hold a callable or an
|
|
842
|
+
# object (the logger, error reporters, hooks, the web data source, a
|
|
843
|
+
# metrics backend) are left as they are: they stay main-Ractor-only.
|
|
844
|
+
# Writers keep working (a setter assigns a new value; Pgbus.configure
|
|
845
|
+
# finalizes again); only in-place mutation of a frozen collection raises.
|
|
846
|
+
# Called once at boot by Pgbus.finalize!.
|
|
847
|
+
def finalize!
|
|
848
|
+
instance_variables.each do |ivar|
|
|
849
|
+
next if FINALIZE_SKIP.include?(ivar)
|
|
850
|
+
|
|
851
|
+
value = instance_variable_get(ivar)
|
|
852
|
+
instance_variable_set(ivar, Shareable.freeze_copy(value)) if Shareable.data?(value)
|
|
853
|
+
end
|
|
854
|
+
@finalized = true
|
|
855
|
+
self
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def finalized?
|
|
859
|
+
@finalized
|
|
860
|
+
end
|
|
861
|
+
|
|
802
862
|
def validate!
|
|
803
863
|
if pool_size && !(pool_size.is_a?(Numeric) && pool_size.positive?)
|
|
804
864
|
raise Pgbus::ConfigurationError, "pool_size must be a positive number or nil (auto-tune)"
|
|
@@ -829,6 +889,8 @@ module Pgbus
|
|
|
829
889
|
end
|
|
830
890
|
raise Pgbus::ConfigurationError, "drain_timeout must be > 0" unless drain_timeout.is_a?(Numeric) && drain_timeout.positive?
|
|
831
891
|
|
|
892
|
+
validate_immediate_shutdown_grace!
|
|
893
|
+
|
|
832
894
|
validate_shutdown_timeout!
|
|
833
895
|
|
|
834
896
|
unless stats_flush_size.is_a?(Integer) && stats_flush_size.positive?
|
|
@@ -884,6 +946,7 @@ module Pgbus
|
|
|
884
946
|
validate_streams!
|
|
885
947
|
validate_metrics_backend!
|
|
886
948
|
validate_fair_share!
|
|
949
|
+
validate_deploy_settings!
|
|
887
950
|
|
|
888
951
|
self
|
|
889
952
|
end
|
|
@@ -1279,15 +1342,17 @@ module Pgbus
|
|
|
1279
1342
|
validate_processes!(options[:processes]) unless options[:processes].nil?
|
|
1280
1343
|
|
|
1281
1344
|
normalized_name = name.to_s
|
|
1282
|
-
@workers
|
|
1345
|
+
existing = @workers || []
|
|
1283
1346
|
|
|
1284
|
-
if
|
|
1347
|
+
if existing.any? { |c| capsule_name(c) == normalized_name }
|
|
1285
1348
|
raise Pgbus::ConfigurationError, "capsule #{name.inspect} is already defined"
|
|
1286
1349
|
end
|
|
1287
1350
|
|
|
1288
1351
|
validate_no_queue_overlap!(queues)
|
|
1289
1352
|
|
|
1290
|
-
|
|
1353
|
+
# A new list rather than <<: after finalize! @workers is frozen, and a
|
|
1354
|
+
# writer keeps working (a later Pgbus.configure finalizes again).
|
|
1355
|
+
@workers = [*existing, { name: normalized_name, queues: queues, threads: threads, **options }]
|
|
1291
1356
|
end
|
|
1292
1357
|
|
|
1293
1358
|
# Look up a capsule by its name. Accepts symbol or string. Returns the
|
|
@@ -1631,6 +1696,10 @@ module Pgbus
|
|
|
1631
1696
|
end
|
|
1632
1697
|
end
|
|
1633
1698
|
|
|
1699
|
+
def rails_logger
|
|
1700
|
+
::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1634
1703
|
# When the logger's previous formatter was extended with
|
|
1635
1704
|
# ActiveSupport::TaggedLogging::Formatter (the case for a Rails
|
|
1636
1705
|
# Rails.logger), extend the replacement pgbus formatter with the same module
|
|
@@ -1718,6 +1787,15 @@ module Pgbus
|
|
|
1718
1787
|
"#{name} must be a Numeric (seconds), ActiveSupport::Duration, or nil to disable, got #{value.inspect}"
|
|
1719
1788
|
end
|
|
1720
1789
|
|
|
1790
|
+
# A finite real number >= 0: NaN is not negative?, and an infinite grace
|
|
1791
|
+
# would never kill a job that does not finish.
|
|
1792
|
+
def validate_immediate_shutdown_grace!
|
|
1793
|
+
grace = immediate_shutdown_grace
|
|
1794
|
+
return if grace.is_a?(Numeric) && grace.real? && grace.finite? && grace >= 0
|
|
1795
|
+
|
|
1796
|
+
raise Pgbus::ConfigurationError, "immediate_shutdown_grace must be a finite number >= 0"
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1721
1799
|
def validate_positive_duration!(numeric, name)
|
|
1722
1800
|
raise Pgbus::ConfigurationError, "#{name} must be a positive number, got #{numeric}" unless numeric.positive?
|
|
1723
1801
|
|
|
@@ -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
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Deploy
|
|
5
|
+
# The app revision this process runs (issue #536). Every heartbeat row
|
|
6
|
+
# carries it in metadata["revision"]; the deploy-task rollover gate
|
|
7
|
+
# compares those against the scheduler's own.
|
|
8
|
+
#
|
|
9
|
+
# First match wins, blank counts as absent:
|
|
10
|
+
# config.app_revision (String or callable) → PGBUS_REVISION → DASH_VERSION
|
|
11
|
+
# → KAMAL_VERSION → the REVISION file at Rails.root (Capistrano) → nil
|
|
12
|
+
#
|
|
13
|
+
# Computed per call, never memoized: it is read once per process
|
|
14
|
+
# registration, and a module-level memo would be unguarded shared state.
|
|
15
|
+
module Revision
|
|
16
|
+
ENV_KEYS = %w[PGBUS_REVISION DASH_VERSION KAMAL_VERSION].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def current(config = Pgbus.configuration)
|
|
21
|
+
from_config(config) || from_env || from_file
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def from_config(config)
|
|
25
|
+
value = config.app_revision
|
|
26
|
+
value = value.call if value.respond_to?(:call)
|
|
27
|
+
presence(value)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Pgbus.logger.warn { "[Pgbus] app_revision raised #{e.class}: #{e.message}; trying the environment" }
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def from_env
|
|
34
|
+
ENV_KEYS.each do |key|
|
|
35
|
+
value = presence(ENV.fetch(key, nil))
|
|
36
|
+
return value if value
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def from_file
|
|
42
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
43
|
+
|
|
44
|
+
path = Rails.root.join("REVISION")
|
|
45
|
+
File.exist?(path) ? presence(File.read(path)) : nil
|
|
46
|
+
rescue SystemCallError, IOError => e
|
|
47
|
+
# Read while every heartbeat initializes: an unreadable optional file
|
|
48
|
+
# must not stop a process from starting.
|
|
49
|
+
Pgbus.logger.warn { "[Pgbus] Could not read #{path}: #{e.class}: #{e.message}" }
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def presence(value)
|
|
54
|
+
string = value.to_s.strip
|
|
55
|
+
string.empty? ? nil : string
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Deploy
|
|
7
|
+
# Has the fleet rolled over to +revision+? (issue #536). A pure read of
|
|
8
|
+
# pgbus_processes: live rows (heartbeat within Heartbeat::ALIVE_THRESHOLD)
|
|
9
|
+
# whose metadata["revision"] differs from +revision+ are lagging (a missing
|
|
10
|
+
# revision is a pre-upgrade process, so it lags too), and at least one live
|
|
11
|
+
# worker on +revision+ must exist. The scheduler and the dashboard both
|
|
12
|
+
# consume the Status, so the page prints the reason the scheduler acts on.
|
|
13
|
+
module Rollover
|
|
14
|
+
# reason – :ready, :no_revision, :lagging or :no_workers.
|
|
15
|
+
# lagging – [{ kind:, hostname:, pid:, revision: }].
|
|
16
|
+
# live_by_revision – { revision => live process count }, nil for rows without one.
|
|
17
|
+
Status = Data.define(:revision, :lagging, :workers_live, :reason, :live_by_revision) do
|
|
18
|
+
def rolled_over?
|
|
19
|
+
reason == :ready
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def check(revision:, now: Time.current)
|
|
26
|
+
cutoff = now - Process::Heartbeat::ALIVE_THRESHOLD
|
|
27
|
+
rows = ProcessEntry.where(last_heartbeat_at: cutoff..).pluck(:kind, :hostname, :pid, :metadata)
|
|
28
|
+
evaluate(rows.map { |kind, hostname, pid, metadata| { kind:, hostname:, pid:, metadata: } }, revision: revision)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# [live, revision] of the newest live scheduler row: the revision the
|
|
32
|
+
# dashboard judges the rollover against (the one the scheduler acts on).
|
|
33
|
+
def scheduler_revision(now: Time.current)
|
|
34
|
+
row = ProcessEntry.where(kind: "scheduler", last_heartbeat_at: (now - Process::Heartbeat::ALIVE_THRESHOLD)..)
|
|
35
|
+
.order(created_at: :desc, id: :desc).first
|
|
36
|
+
row ? [true, revision_of(row.metadata)] : [false, nil]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# rows – live process rows as { kind:, hostname:, pid:, metadata: }.
|
|
40
|
+
def evaluate(rows, revision:)
|
|
41
|
+
live = rows.map { |r| { kind: r[:kind], hostname: r[:hostname], pid: r[:pid], revision: revision_of(r[:metadata]) } }
|
|
42
|
+
lagging = live.reject { |r| revision && r[:revision] == revision }
|
|
43
|
+
workers_live = live.count { |r| r[:kind] == "worker" && revision && r[:revision] == revision }
|
|
44
|
+
|
|
45
|
+
Status.new(revision: revision, lagging: revision ? lagging : [], workers_live: workers_live,
|
|
46
|
+
reason: reason_for(revision, lagging, workers_live), live_by_revision: live.map { |r| r[:revision] }.tally)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reason_for(revision, lagging, workers_live)
|
|
50
|
+
return :no_revision if revision.nil?
|
|
51
|
+
return :lagging if lagging.any?
|
|
52
|
+
return :no_workers if workers_live.zero?
|
|
53
|
+
|
|
54
|
+
:ready
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def revision_of(metadata)
|
|
58
|
+
metadata = JSON.parse(metadata) if metadata.is_a?(String)
|
|
59
|
+
metadata.is_a?(Hash) ? metadata["revision"] : nil
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|