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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +345 -142
  3. data/README.md +49 -0
  4. data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
  5. data/app/controllers/pgbus/frontends_controller.rb +10 -8
  6. data/app/helpers/pgbus/application_helper.rb +4 -3
  7. data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
  8. data/app/helpers/pgbus/processes_helper.rb +1 -1
  9. data/app/models/pgbus/deploy_task.rb +104 -0
  10. data/app/models/pgbus/deploy_task_run.rb +45 -0
  11. data/app/models/pgbus/job_stat.rb +4 -0
  12. data/app/models/pgbus/processed_event.rb +3 -1
  13. data/app/models/pgbus/stream_queue.rb +34 -3
  14. data/app/models/pgbus/stream_stat.rb +3 -0
  15. data/app/views/layouts/pgbus/application.html.erb +2 -0
  16. data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
  17. data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
  18. data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
  19. data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
  20. data/config/i18n-tasks.yml +2 -0
  21. data/config/locales/da.yml +97 -0
  22. data/config/locales/de.yml +97 -0
  23. data/config/locales/en.yml +97 -0
  24. data/config/locales/es.yml +97 -0
  25. data/config/locales/fi.yml +97 -0
  26. data/config/locales/fr.yml +97 -0
  27. data/config/locales/it.yml +97 -0
  28. data/config/locales/ja.yml +97 -0
  29. data/config/locales/nb.yml +97 -0
  30. data/config/locales/nl.yml +97 -0
  31. data/config/locales/pt.yml +97 -0
  32. data/config/locales/sv.yml +97 -0
  33. data/config/routes.rb +7 -0
  34. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  35. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  36. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  37. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  38. data/lib/pgbus/active_job/current_attributes.rb +3 -1
  39. data/lib/pgbus/batch.rb +17 -9
  40. data/lib/pgbus/cli.rb +3 -0
  41. data/lib/pgbus/client/read_after.rb +10 -1
  42. data/lib/pgbus/client.rb +54 -9
  43. data/lib/pgbus/concurrency.rb +3 -0
  44. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  45. data/lib/pgbus/configuration.rb +87 -9
  46. data/lib/pgbus/current_attributes.rb +3 -1
  47. data/lib/pgbus/dedup_cache.rb +15 -7
  48. data/lib/pgbus/deploy/revision.rb +59 -0
  49. data/lib/pgbus/deploy/rollover.rb +65 -0
  50. data/lib/pgbus/deploy/runner.rb +259 -0
  51. data/lib/pgbus/deploy/task.rb +105 -0
  52. data/lib/pgbus/engine.rb +28 -0
  53. data/lib/pgbus/event_bus/handler.rb +16 -4
  54. data/lib/pgbus/execution_pools/async_pool.rb +23 -2
  55. data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
  56. data/lib/pgbus/execution_pools.rb +26 -0
  57. data/lib/pgbus/generators/migration_detector.rb +11 -0
  58. data/lib/pgbus/instrumentation.rb +3 -0
  59. data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
  60. data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
  61. data/lib/pgbus/integrations/appsignal.rb +26 -16
  62. data/lib/pgbus/mcp/rack_app.rb +1 -1
  63. data/lib/pgbus/mcp/server.rb +2 -1
  64. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  65. data/lib/pgbus/mcp.rb +1 -0
  66. data/lib/pgbus/metrics/subscriber.rb +35 -21
  67. data/lib/pgbus/outbox/poller.rb +1 -0
  68. data/lib/pgbus/process/consumer.rb +2 -1
  69. data/lib/pgbus/process/consumer_priority.rb +1 -1
  70. data/lib/pgbus/process/dispatcher.rb +18 -1
  71. data/lib/pgbus/process/heartbeat.rb +7 -2
  72. data/lib/pgbus/process/lifecycle.rb +5 -5
  73. data/lib/pgbus/process/notify_listener.rb +32 -18
  74. data/lib/pgbus/process/supervisor.rb +35 -3
  75. data/lib/pgbus/process/wake_pipe.rb +4 -1
  76. data/lib/pgbus/process/worker.rb +5 -2
  77. data/lib/pgbus/recurring/scheduler.rb +54 -20
  78. data/lib/pgbus/retry_backoff.rb +3 -0
  79. data/lib/pgbus/shareable.rb +70 -0
  80. data/lib/pgbus/streams/broadcastable_override.rb +3 -0
  81. data/lib/pgbus/streams/presence.rb +6 -7
  82. data/lib/pgbus/streams.rb +38 -27
  83. data/lib/pgbus/testing.rb +19 -3
  84. data/lib/pgbus/uniqueness.rb +3 -0
  85. data/lib/pgbus/version.rb +1 -1
  86. data/lib/pgbus/visibility_heartbeat.rb +51 -34
  87. data/lib/pgbus/web/authentication.rb +19 -2
  88. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  89. data/lib/pgbus/web/data_source/list_counts.rb +10 -7
  90. data/lib/pgbus/web/data_source.rb +1 -0
  91. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  92. data/lib/pgbus/web/health_server.rb +4 -1
  93. data/lib/pgbus/web/process_activity.rb +1 -1
  94. data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
  95. data/lib/pgbus/web/streamer/hub_client.rb +4 -1
  96. data/lib/pgbus/web/streamer/listener.rb +10 -1
  97. data/lib/pgbus/web/streamer/master_hub.rb +11 -5
  98. data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
  99. data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
  100. data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
  101. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
  102. data/lib/pgbus.rb +92 -30
  103. data/sig/pgbus/active_job/adapter.rbs +57 -0
  104. data/sig/pgbus/client.rbs +78 -0
  105. data/sig/pgbus/configuration.rbs +326 -0
  106. data/sig/pgbus/streams/stream.rbs +25 -0
  107. data/sig/pgbus/streams.rbs +22 -0
  108. data/sig/pgbus.rbs +73 -0
  109. 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
- PGMQ_REQUIRE_MUTEX.synchronize do
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
- def wait_for_notify(queue_name, timeout: nil, &block)
924
- full_name = config.queue_name(queue_name)
925
- with_stale_connection_retry do
926
- synchronized { @pgmq.wait_for_notify(full_name, timeout: timeout, &block) }
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
- self.class.pgmq_install_mutex.synchronize do
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) }`:
@@ -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
@@ -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
- attr_accessor :logger
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 = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stdout)
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 = @logger&.formatter
666
+ old_formatter = logger&.formatter
637
667
  return unless pgbus_installable_formatter?(old_formatter)
638
668
 
639
- new_formatter = case format
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
- @logger.formatter = new_formatter
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 @workers.any? { |c| capsule_name(c) == normalized_name }
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
- @workers << { name: normalized_name, queues: queues, threads: threads, **options }
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
@@ -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
@@ -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