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
@@ -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
@@ -26,7 +26,8 @@ module Pgbus
26
26
  Tools::ConcurrencyTool,
27
27
  Tools::ThroughputTool,
28
28
  Tools::StatsTool,
29
- Tools::RecurringTool
29
+ Tools::RecurringTool,
30
+ Tools::DeployTasksTool
30
31
  ].freeze
31
32
 
32
33
  INSTRUCTIONS = <<~TEXT
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Deploy tasks with their state and the rollover the scheduler waits on.
7
+ # Maps to DataSource#deploy_tasks and #deploy_rollover. Job arguments are
8
+ # not part of the list payload (and are dropped here if a data source
9
+ # ever adds them), so the tool never carries them.
10
+ class DeployTasksTool < BaseTool
11
+ tool_name "pgbus_deploy_tasks"
12
+ title "Pgbus Deploy Tasks"
13
+ description <<~DESC
14
+ Deploy tasks: one-time jobs the scheduler enqueues once every live pgbus process runs the new app revision. Lists each task's state (disabled, waiting, due, ran, never) and the rollover status the scheduler waits on.
15
+ Returns every task unless page or per_page is given; then it is
16
+ paginated (per_page defaults to 100, capped at 100). The response
17
+ carries page, per_page, total (capped at 10000 when paginated; the
18
+ exact row count when not) and has_more; per_page is null when
19
+ unpaginated.
20
+ DESC
21
+
22
+ MAX_PER_PAGE = 100
23
+ MAX_PAGE = 1_000
24
+
25
+ input_schema(
26
+ properties: {
27
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
28
+ per_page: { type: "integer", description: "Rows per page (default 100 once paginating, max 100).", minimum: 1 }
29
+ },
30
+ required: []
31
+ )
32
+
33
+ def self.call(page: nil, per_page: nil, server_context: nil)
34
+ data_source = data_source_from(server_context)
35
+ return unpaged(data_source) if page.nil? && per_page.nil?
36
+
37
+ per_page = (per_page || MAX_PER_PAGE).to_i.clamp(1, MAX_PER_PAGE)
38
+ page = (page || 1).to_i.clamp(1, MAX_PAGE)
39
+ tasks = data_source.deploy_tasks(page: page, per_page: per_page)
40
+ count = data_source.list_count(:deploy_tasks)
41
+
42
+ json_response(
43
+ payload(data_source, tasks).merge(
44
+ page: page, per_page: per_page, total: count.total,
45
+ has_more: more_pages?(count, page: page, per_page: per_page, shown: tasks.size)
46
+ )
47
+ )
48
+ end
49
+
50
+ def self.unpaged(data_source)
51
+ tasks = data_source.deploy_tasks
52
+ json_response(
53
+ payload(data_source, tasks).merge(page: 1, per_page: nil, total: tasks.size, has_more: false)
54
+ )
55
+ end
56
+ private_class_method :unpaged
57
+
58
+ def self.payload(data_source, tasks)
59
+ rollover = data_source.deploy_rollover
60
+ { rollover: serialize(rollover), tasks: tasks.map { |row| task_hash(row, rollover) } }
61
+ end
62
+ private_class_method :payload
63
+
64
+ def self.task_hash(row, rollover)
65
+ state = Pgbus::Web::DeployTaskState.present(row, rollover)
66
+ serialize(row.except(:arguments)).merge(
67
+ "state" => state.state, "reason" => state.reason_key, "reason_args" => serialize(state.reason_args)
68
+ )
69
+ end
70
+ private_class_method :task_hash
71
+
72
+ # Times as ISO 8601 UTC, symbols as strings, string keys throughout.
73
+ def self.serialize(value)
74
+ case value
75
+ when Hash then value.to_h { |key, val| [key.to_s, serialize(val)] }
76
+ when Array then value.map { |val| serialize(val) }
77
+ when Symbol then value.to_s
78
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
79
+ else value
80
+ end
81
+ end
82
+ private_class_method :serialize
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/pgbus/mcp.rb CHANGED
@@ -28,6 +28,7 @@ module Pgbus
28
28
  pgbus/mcp/tools/throughput_tool
29
29
  pgbus/mcp/tools/stats_tool
30
30
  pgbus/mcp/tools/recurring_tool
31
+ pgbus/mcp/tools/deploy_tasks_tool
31
32
  pgbus/mcp/server
32
33
  pgbus/mcp/runner
33
34
  pgbus/mcp/rack_app
@@ -17,15 +17,45 @@ module Pgbus
17
17
  METRIC_PREFIX = "pgbus_"
18
18
  private_constant :METRIC_PREFIX
19
19
 
20
- @subscriptions = []
20
+ # Installed once at boot (the engine's after_initialize) and torn down by
21
+ # reset! in specs; @install_lock makes the check-then-install atomic so
22
+ # two callers can never subscribe twice (issue #527). Handlers read
23
+ # @backend through the private reader: a reference swapped whole.
24
+ @install_lock = Mutex.new
25
+ @subscriptions = [].freeze
21
26
  @backend = nil
27
+ @installed = false
22
28
 
23
29
  class << self
24
30
  def install!(backend:)
25
- return false if @installed
31
+ @install_lock.synchronize do
32
+ next false if @installed
26
33
 
27
- @backend = backend
28
- @subscriptions = [
34
+ @backend = backend
35
+ @subscriptions = subscribe_all
36
+ @installed = true
37
+ end
38
+ end
39
+
40
+ def installed?
41
+ @install_lock.synchronize { @installed }
42
+ end
43
+
44
+ def reset!
45
+ @install_lock.synchronize do
46
+ @subscriptions.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
47
+ @subscriptions = [].freeze
48
+ @backend = nil
49
+ @installed = false
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :backend
56
+
57
+ def subscribe_all
58
+ [
29
59
  subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
30
60
  subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
31
61
  subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
@@ -43,25 +73,9 @@ module Pgbus
43
73
  subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
44
74
  subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
45
75
  subscribe("pgbus.client.pool") { |event| on_client_pool(event) }
46
- ]
47
- @installed = true
48
- end
49
-
50
- def installed?
51
- @installed == true
52
- end
53
-
54
- def reset!
55
- @subscriptions&.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
56
- @subscriptions = []
57
- @backend = nil
58
- @installed = false
76
+ ].freeze
59
77
  end
60
78
 
61
- private
62
-
63
- attr_reader :backend
64
-
65
79
  def subscribe(name, &block)
66
80
  ActiveSupport::Notifications.subscribe(name) do |*args|
67
81
  event = ActiveSupport::Notifications::Event.new(*args)
@@ -139,6 +139,7 @@ module Pgbus
139
139
  def start_heartbeat
140
140
  @heartbeat = Process::Heartbeat.new(
141
141
  kind: "outbox_poller",
142
+ config: @config,
142
143
  metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid }
143
144
  )
144
145
  @heartbeat.start
@@ -170,7 +170,7 @@ module Pgbus
170
170
  def immediate_shutdown
171
171
  @shutting_down = true
172
172
  @wake_signal.notify!
173
- @pool.kill
173
+ ExecutionPools.terminate(@pool, grace: config.immediate_shutdown_grace)
174
174
  end
175
175
 
176
176
  private
@@ -615,6 +615,7 @@ module Pgbus
615
615
  def start_heartbeat
616
616
  @heartbeat = Heartbeat.new(
617
617
  kind: "consumer",
618
+ config: @config,
618
619
  metadata: {
619
620
  topics: topics, threads: threads, pid: ::Process.pid, execution_mode: @execution_mode,
620
621
  started_at: @started_at.to_f, supervisor_pid: ::Process.ppid
@@ -17,8 +17,8 @@ module Pgbus
17
17
  # 0.1s polling interval.
18
18
  CACHE_TTL = 5
19
19
 
20
- @cache = {}
21
20
  @cache_mutex = Mutex.new
21
+ @cache = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable -- only read or written under @cache_mutex
22
22
 
23
23
  # Check if this worker should yield to a higher-priority worker.
24
24
  # Returns true if a higher-priority healthy worker exists for
@@ -13,6 +13,7 @@ module Pgbus
13
13
  CONCURRENCY_INTERVAL = 300 # Run concurrency cleanup every 5 minutes
14
14
  BATCH_CLEANUP_INTERVAL = 3600 # Run batch cleanup every hour
15
15
  RECURRING_CLEANUP_INTERVAL = 3600 # Run recurring execution cleanup every hour
16
+ DEPLOY_RUN_CLEANUP_INTERVAL = 3600 # Run deploy task run cleanup every hour
16
17
  ARCHIVE_COMPACTION_INTERVAL = 3600 # Run archive compaction every hour
17
18
  OUTBOX_CLEANUP_INTERVAL = 3600 # Run outbox cleanup every hour
18
19
  JOB_LOCK_CLEANUP_INTERVAL = 300 # Run job lock cleanup every 5 minutes
@@ -52,7 +53,8 @@ module Pgbus
52
53
  MAINTENANCE_TIMESTAMPS = %i[
53
54
  @last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
54
55
  @last_batch_sweep_at
55
- @last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
56
+ @last_recurring_cleanup_at @last_deploy_run_cleanup_at @last_archive_compaction_at
57
+ @last_stream_archive_compaction_at
56
58
  @last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
57
59
  @last_orphan_stream_sweep_at @last_table_maintenance_at
58
60
  ].freeze
@@ -236,6 +238,7 @@ module Pgbus
236
238
  results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
237
239
  results << run_if_due(now, :@last_batch_sweep_at, config.batch_sweep_interval) { sweep_stalled_batches }
238
240
  results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
241
+ results << run_if_due(now, :@last_deploy_run_cleanup_at, DEPLOY_RUN_CLEANUP_INTERVAL) { cleanup_deploy_task_runs }
239
242
  results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
240
243
  results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
241
244
  results << run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
@@ -681,6 +684,19 @@ module Pgbus
681
684
  e
682
685
  end
683
686
 
687
+ # Guarded on the table: deploy tasks default on, and an install that has
688
+ # not run pgbus:add_deploy_tasks must not warn every hour.
689
+ def cleanup_deploy_task_runs
690
+ retention = config.deploy_task_run_retention
691
+ return unless retention&.positive? && DeployTaskRun.table_exists?
692
+
693
+ deleted = DeployTaskRun.prunable_before(Time.current - retention).delete_all
694
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old deploy task runs" } if deleted.positive?
695
+ rescue StandardError => e
696
+ log_maintenance_failure("Deploy task run cleanup", e)
697
+ e
698
+ end
699
+
684
700
  def monotonic_now
685
701
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
686
702
  end
@@ -697,6 +713,7 @@ module Pgbus
697
713
  def start_heartbeat
698
714
  @heartbeat = Heartbeat.new(
699
715
  kind: "dispatcher",
716
+ config: @config,
700
717
  metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid },
701
718
  loop_tick_supplier: -> { @loop_tick_at.get }
702
719
  )
@@ -11,9 +11,14 @@ module Pgbus
11
11
 
12
12
  attr_reader :process_entry
13
13
 
14
- def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil)
14
+ # config: the owning process's configuration, for the app revision.
15
+ def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil,
16
+ config: Pgbus.configuration)
15
17
  @kind = kind
16
- @metadata = metadata
18
+ # Every kind's row carries the app revision from its first
19
+ # registration on, nil included: the deploy-task rollover gate reads
20
+ # it (issue #536), and beat_metadata dups this hash so it survives.
21
+ @metadata = metadata.merge(revision: Deploy::Revision.current(config))
17
22
  @on_beat = on_beat
18
23
  @loop_tick_supplier = loop_tick_supplier
19
24
  @metadata_supplier = metadata_supplier
@@ -22,11 +22,11 @@ module Pgbus
22
22
  STATES = %i[starting running paused draining stopped].freeze
23
23
 
24
24
  TRANSITIONS = {
25
- starting: %i[running stopped],
26
- running: %i[paused draining stopped],
27
- paused: %i[running draining stopped],
28
- draining: %i[stopped],
29
- stopped: []
25
+ starting: %i[running stopped].freeze,
26
+ running: %i[paused draining stopped].freeze,
27
+ paused: %i[running draining stopped].freeze,
28
+ draining: %i[stopped].freeze,
29
+ stopped: [].freeze
30
30
  }.freeze
31
31
 
32
32
  attr_reader :state
@@ -64,6 +64,9 @@ module Pgbus
64
64
  @health_check_ms = health_check_ms
65
65
  @logger = logger
66
66
  @state_mutex = Mutex.new
67
+ # Serializes #start and #stop whole (see #start). Never taken by the
68
+ # listener thread, so #stop can join it while holding this.
69
+ @lifecycle_mutex = Mutex.new
67
70
  @listening_to = Set.new
68
71
  @commands = Queue.new
69
72
  @running = false
@@ -98,33 +101,44 @@ module Pgbus
98
101
  @state_mutex.synchronize { @delivering }
99
102
  end
100
103
 
104
+ # Under @lifecycle_mutex, like #stop: a stop that ran between the
105
+ # @running check and Thread.new found no thread to join, and the thread
106
+ # started after it was joined by nobody (and a restart could run a
107
+ # second run_loop beside it).
101
108
  def start
102
- @state_mutex.synchronize do
103
- return self if @running
109
+ @lifecycle_mutex.synchronize do
110
+ @state_mutex.synchronize do
111
+ return self if @running
104
112
 
105
- @running = true
113
+ @running = true
114
+ end
115
+ @physical_queues.each { |q| @commands << [:listen, q] }
116
+ # The listener's one thread, owned by this object: started once (the
117
+ # @running check above, with #start and #stop serialized), the sole
118
+ # owner of @conn, and joined by #stop after it clears @running.
119
+ @thread = Thread.new { run_loop } # rubocop:disable ThreadSafety/NewThread
106
120
  end
107
- @physical_queues.each { |q| @commands << [:listen, q] }
108
- @thread = Thread.new { run_loop }
109
121
  self
110
122
  end
111
123
 
112
124
  def stop
113
- @state_mutex.synchronize do
114
- return self unless @running
125
+ @lifecycle_mutex.synchronize do
126
+ @state_mutex.synchronize do
127
+ return self unless @running
115
128
 
116
- @running = false
129
+ @running = false
130
+ end
131
+ @commands << [:stop]
132
+ # Deliberately does NOT touch @conn. PG::Connection is not thread-safe
133
+ # and #close is PQfinish: it frees the PGconn and its OpenSSL objects
134
+ # out from under whatever libpq call the listener thread is making on
135
+ # the same connection. That is a use-after-free — a process-killing
136
+ # SEGV, not a rescuable PG::Error (issue #375). The listener thread is
137
+ # the sole owner of @conn for its entire life and closes it in
138
+ # run_loop's ensure; clearing @running above is the whole stop signal.
139
+ @thread&.join(stop_join_timeout)
140
+ @thread = nil
117
141
  end
118
- @commands << [:stop]
119
- # Deliberately does NOT touch @conn. PG::Connection is not thread-safe
120
- # and #close is PQfinish: it frees the PGconn and its OpenSSL objects
121
- # out from under whatever libpq call the listener thread is making on
122
- # the same connection. That is a use-after-free — a process-killing
123
- # SEGV, not a rescuable PG::Error (issue #375). The listener thread is
124
- # the sole owner of @conn for its entire life and closes it in
125
- # run_loop's ensure; clearing @running above is the whole stop signal.
126
- @thread&.join(stop_join_timeout)
127
- @thread = nil
128
142
  self
129
143
  end
130
144
 
@@ -425,9 +425,12 @@ module Pgbus
425
425
  ErrorReporter.report(e, { action: "fork_dispatcher" })
426
426
  end
427
427
 
428
+ # The scheduler runs recurring tasks and deploy tasks (issue #536);
429
+ # either one being enabled and configured forks it.
428
430
  def boot_scheduler
429
- return unless config.recurring_enabled
430
- return unless recurring_tasks_configured?
431
+ recurring = config.recurring_enabled && recurring_tasks_configured?
432
+ deploy = config.deploy_tasks_enabled && deploy_tasks_configured?
433
+ return unless recurring || deploy
431
434
 
432
435
  fork_scheduler
433
436
  end
@@ -438,7 +441,8 @@ module Pgbus
438
441
  restore_signals
439
442
  setup_child_process
440
443
  load_rails_app
441
- load_recurring_config
444
+ load_recurring_config if config.recurring_enabled
445
+ load_deploy_tasks_config if config.deploy_tasks_enabled
442
446
  bootstrap_queues!
443
447
  scheduler = Recurring::Scheduler.new(config: config)
444
448
  scheduler.run
@@ -488,6 +492,33 @@ module Pgbus
488
492
  config.recurring_tasks = Recurring::ConfigLoader.load(path)
489
493
  end
490
494
 
495
+ def deploy_tasks_configured?
496
+ return true if config.deploy_tasks&.any?
497
+ # Explicit files replace the default file, as in load_deploy_tasks_config.
498
+ return config.deploy_tasks_files.any? { |f| File.exist?(f.to_s) } if config.deploy_tasks_files
499
+
500
+ path = default_deploy_tasks_path
501
+ path ? File.exist?(path.to_s) : false
502
+ end
503
+
504
+ # The engine initializer loads config/deploy_tasks.yml in a Rails boot;
505
+ # this covers a scheduler fork whose config was not loaded that way.
506
+ def load_deploy_tasks_config
507
+ return if config.deploy_tasks&.any?
508
+
509
+ files = config.deploy_tasks_files
510
+ tasks = if files
511
+ Recurring::ConfigLoader.load_all(files)
512
+ elsif (path = default_deploy_tasks_path) && File.exist?(path.to_s)
513
+ Recurring::ConfigLoader.load(path)
514
+ end
515
+ config.deploy_tasks = tasks if tasks&.any?
516
+ end
517
+
518
+ def default_deploy_tasks_path
519
+ Rails.root.join("config", "deploy_tasks.yml") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
520
+ end
521
+
491
522
  def boot_consumers
492
523
  return unless config.event_consumers
493
524
 
@@ -903,6 +934,7 @@ module Pgbus
903
934
  def start_heartbeat
904
935
  @heartbeat = Heartbeat.new(
905
936
  kind: "supervisor",
937
+ config: @config,
906
938
  metadata: { pid: ::Process.pid, hostname: Socket.gethostname, started_at: Time.now.to_f }
907
939
  )
908
940
  @heartbeat.start
@@ -53,7 +53,10 @@ module Pgbus
53
53
 
54
54
  @running = true
55
55
  end
56
- @thread = Thread.new { run_loop }
56
+ # The pipe watcher's one thread, owned by this object: started once
57
+ # (the @running check above, under @state_mutex) and stopped by #stop,
58
+ # which closes the pipe under it and joins it.
59
+ @thread = Thread.new { run_loop } # rubocop:disable ThreadSafety/NewThread
57
60
  self
58
61
  end
59
62