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
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Deploy
5
+ # Fires deploy tasks once the fleet has rolled over (issue #536). Ticked
6
+ # by the recurring scheduler's loop; one instance per scheduler process,
7
+ # so its throttle and log state are plain instance state on that loop.
8
+ #
9
+ # A tick: cheap pending check → the rollover gate (at most every
10
+ # ROLLOVER_CHECK_INTERVAL seconds) → for each declared, enabled, armed
11
+ # task in key order: claim the arm → perform_later → record the run. A
12
+ # failed enqueue re-arms the task, logs and reports. No surrounding
13
+ # transaction: the adapter defers an enqueue inside one to its commit.
14
+ class Runner
15
+ ROLLOVER_CHECK_INTERVAL = 5 # seconds
16
+ # A task whose enqueue failed is retried after 5 s, then 10, 20, … up to
17
+ # 5 minutes, so a deterministic failure (a before_enqueue that always
18
+ # aborts, a uniqueness reject while the duplicate is queued) does not
19
+ # retry and report on every tick.
20
+ RETRY_BACKOFF_BASE = 5 # seconds
21
+ RETRY_BACKOFF_MAX = 300 # seconds
22
+ ROLLOVER = "rollover"
23
+ MANUAL = "manual"
24
+
25
+ attr_reader :revision
26
+
27
+ def initialize(config: Pgbus.configuration, revision: Revision.current(config))
28
+ @config = config
29
+ @revision = revision
30
+ @tasks = nil
31
+ @status = nil
32
+ @checked_at = nil
33
+ @logged_wait = nil
34
+ @failures = {}
35
+ @synced = nil
36
+ @sync_failures = 0
37
+ @sync_retry_at = nil
38
+ end
39
+
40
+ # The valid declared tasks, in key order. A class: that does not resolve
41
+ # to an ActiveJob is skipped with a warning, like an invalid task.
42
+ def tasks
43
+ @tasks ||= load_tasks
44
+ end
45
+
46
+ def task_count
47
+ tasks.size
48
+ end
49
+
50
+ # true once synced. A failure (a database blip at boot) is retried from
51
+ # tick with the enqueue backoff, so the scheduler keeps no unarmed tasks.
52
+ def sync!(now: Time.current)
53
+ if revision.nil? && @synced.nil?
54
+ Pgbus.logger.warn do
55
+ "[Pgbus] No app revision configured (config.app_revision, PGBUS_REVISION, DASH_VERSION, KAMAL_VERSION " \
56
+ "or a REVISION file): deploy tasks wait until one is set"
57
+ end
58
+ end
59
+ DeployTask.sync_from_config!(tasks, revision: revision, now: now)
60
+ Pgbus.logger.info { "[Pgbus] Synced #{tasks.size} deploy task(s) for revision #{revision.inspect}" }
61
+ @synced = true
62
+ rescue StandardError => e
63
+ @sync_failures += 1
64
+ delay = retry_delay(@sync_failures)
65
+ @sync_retry_at = monotonic_now + delay
66
+ # Reported (and logged at error by the reporter) once; repeats of a
67
+ # persistent failure drop to debug instead of reporting every retry.
68
+ if @sync_failures == 1
69
+ ErrorReporter.report(e, { action: "deploy_tasks_sync", retry_in: delay })
70
+ else
71
+ Pgbus.logger.debug { "[Pgbus] Deploy task sync still failing (retrying in #{delay}s): #{e.class}: #{e.message}" }
72
+ end
73
+ @synced = false
74
+ end
75
+
76
+ def tick(now = Time.current)
77
+ return if tasks.empty?
78
+ return if @synced == false && !retry_sync(now)
79
+ return unless DeployTask.pending?
80
+
81
+ status = gate_status
82
+ return log_waiting(status) unless status.rolled_over?
83
+
84
+ @logged_wait = nil
85
+ rows = DeployTask.where(key: tasks.map(&:key)).index_by(&:key)
86
+ tasks.each do |task|
87
+ row = rows[task.key]
88
+ fire(task, row, now) if row&.enabled && row.armed? && !backing_off?(task.key)
89
+ end
90
+ end
91
+
92
+ # Enqueue +task+ through ActiveJob and record the run. :enqueued, or
93
+ # :failed (logged and reported) when perform_later raised or did not
94
+ # enqueue the job. Run now calls this directly: no gate, no claim.
95
+ def enqueue(task, triggered_by:, now: Time.current)
96
+ payload = { task: task.key, version: task.version, revision: revision, triggered_by: triggered_by }
97
+ job = Instrumentation.instrument("pgbus.deploy_task.enqueue", payload) { submit(task) }
98
+ record_run(task, job, triggered_by, now)
99
+ :enqueued
100
+ rescue StandardError => e
101
+ Pgbus.logger.error do
102
+ "[Pgbus] Deploy task #{task.key} (version #{task.version}, #{triggered_by}) was not enqueued: " \
103
+ "#{e.class}: #{e.message}"
104
+ end
105
+ ErrorReporter.report(e, { action: "deploy_task_enqueue", task: task.key, version: task.version,
106
+ revision: revision, triggered_by: triggered_by })
107
+ :failed
108
+ end
109
+
110
+ private
111
+
112
+ def load_tasks
113
+ (@config.deploy_tasks || {}).filter_map { |key, options| build_task(key, options) }.sort_by(&:key)
114
+ end
115
+
116
+ def build_task(key, options)
117
+ options = options.is_a?(Hash) ? options.transform_keys(&:to_sym) : {}
118
+ task = Task.from_configuration(key, **options)
119
+ return skip_task(key, task.errors.join(", ")) unless task.valid?
120
+ return skip_task(key, "#{task.class_name} is not an ActiveJob") if task.class_name && !active_job?(task.job_class)
121
+
122
+ task.warnings.each { |warning| Pgbus.logger.warn { "[Pgbus] Deploy task '#{key}': #{warning}" } }
123
+ task
124
+ rescue StandardError => e
125
+ # A malformed entry (a Hash where a scalar belongs) skips that task
126
+ # instead of crashing the scheduler at boot.
127
+ skip_task(key, "#{e.class}: #{e.message}")
128
+ end
129
+
130
+ def active_job?(klass)
131
+ klass.is_a?(Class) && defined?(::ActiveJob::Base) && klass <= ::ActiveJob::Base
132
+ end
133
+
134
+ def skip_task(key, reason)
135
+ Pgbus.logger.warn { "[Pgbus] Skipping invalid deploy task '#{key}': #{reason}" }
136
+ nil
137
+ end
138
+
139
+ def gate_status
140
+ at = monotonic_now
141
+ return @status if @checked_at && at - @checked_at < ROLLOVER_CHECK_INTERVAL
142
+
143
+ # Stamped only after a successful check: a raising check must not
144
+ # leave a nil status cached for the next ticks.
145
+ @status = Rollover.check(revision: revision)
146
+ @checked_at = at
147
+ @status
148
+ end
149
+
150
+ def retry_sync(now)
151
+ return false if monotonic_now < @sync_retry_at
152
+
153
+ sync!(now: now)
154
+ end
155
+
156
+ def log_waiting(status)
157
+ summary = waiting_summary(status)
158
+ return if summary == @logged_wait
159
+
160
+ @logged_wait = summary
161
+ Pgbus.logger.info { "[Pgbus] Deploy tasks waiting for rollover: #{summary}" }
162
+ end
163
+
164
+ def waiting_summary(status)
165
+ case status.reason
166
+ when :no_revision then "app revision not configured"
167
+ when :no_workers then "no worker on #{revision} yet"
168
+ else
169
+ processes = status.lagging.map { |p| "#{p[:kind]}@#{p[:hostname]} pid #{p[:pid]} (#{p[:revision] || "no revision"})" }
170
+ "#{status.lagging.size} process(es) not on #{revision}: #{processes.join(", ")}"
171
+ end
172
+ end
173
+
174
+ # An arm for another version or revision belongs to code no live
175
+ # process runs any more (the gate is open on this revision), so when
176
+ # this runner's own version is owed it takes the arm over; otherwise
177
+ # the claim below fails and nothing fires.
178
+ def fire(task, row, now)
179
+ armed_for = task.armed_for(revision)
180
+ row.take_over!(armed_for: armed_for, now: now) if row.armed_for != armed_for && DeployTask.owed?(task, revision)
181
+ return unless row.claim!(armed_for: armed_for, now: now)
182
+ return @failures.delete(task.key) if enqueue(task, triggered_by: ROLLOVER, now: now) == :enqueued
183
+
184
+ rearm(task, row, armed_for)
185
+ end
186
+
187
+ def rearm(task, row, armed_for)
188
+ delay = note_failure(task.key)
189
+ row.rearm!(armed_for: armed_for, now: Time.current)
190
+ Pgbus.logger.warn { "[Pgbus] Deploy task #{task.key} re-armed; retrying in #{delay}s" }
191
+ rescue StandardError => e
192
+ Pgbus.logger.error { "[Pgbus] Could not re-arm deploy task #{task.key}: #{e.class}: #{e.message}" }
193
+ ErrorReporter.report(e, { action: "deploy_task_rearm", task: task.key, armed_for: armed_for })
194
+ end
195
+
196
+ def backing_off?(key)
197
+ failure = @failures[key]
198
+ !failure.nil? && monotonic_now < failure[:retry_at]
199
+ end
200
+
201
+ # Records one more failed enqueue for +key+ and returns the seconds until its retry.
202
+ def note_failure(key)
203
+ count = @failures.fetch(key, { count: 0 })[:count] + 1
204
+ delay = retry_delay(count)
205
+ @failures[key] = { count: count, retry_at: monotonic_now + delay }
206
+ delay
207
+ end
208
+
209
+ def retry_delay(count)
210
+ [RETRY_BACKOFF_BASE * (2**(count - 1)), RETRY_BACKOFF_MAX].min
211
+ end
212
+
213
+ def monotonic_now
214
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
215
+ end
216
+
217
+ # Builds the job and enqueues it directly (what perform_later does), so
218
+ # the job, and its enqueue_error, is in hand on every Rails version:
219
+ # perform_later returns false on failure, and on Rails 7.1 a .set(...)
220
+ # job never yields it to perform_later's block.
221
+ def submit(task)
222
+ job = job_class_for(task).new(*job_arguments(task))
223
+ return job if job.enqueue(job_options(task))
224
+
225
+ error = job.enqueue_error
226
+ raise EnqueueError, error ? "#{error.class}: #{error.message}" : "the job was not enqueued (a before_enqueue callback aborted)"
227
+ end
228
+
229
+ def job_class_for(task)
230
+ return Recurring::CommandJob if task.command
231
+
232
+ klass = task.job_class
233
+ raise EnqueueError, "#{task.class_name} does not resolve to a job class" unless active_job?(klass)
234
+
235
+ klass
236
+ end
237
+
238
+ def job_options(task)
239
+ priority = task.command || task.priority.zero? ? nil : task.priority
240
+ { queue: task.queue_name, priority: priority }.compact
241
+ end
242
+
243
+ def job_arguments(task)
244
+ task.command ? [task.command] : task.arguments
245
+ end
246
+
247
+ def record_run(task, job, triggered_by, now)
248
+ DeployTaskRun.create!(task_key: task.key, version: task.version, revision: revision,
249
+ triggered_by: triggered_by, queue_name: job.queue_name,
250
+ msg_id: job.provider_job_id, run_at: now)
251
+ rescue StandardError => e
252
+ Pgbus.logger.error do
253
+ "[Pgbus] Deploy task #{task.key} was enqueued but its run was not recorded: #{e.class}: #{e.message}"
254
+ end
255
+ ErrorReporter.report(e, { action: "deploy_task_record_run", task: task.key, triggered_by: triggered_by })
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Deploy
5
+ # One declared deploy task (issue #536): an entry of
6
+ # config/deploy_tasks.yml. The sibling of Recurring::Task without a
7
+ # schedule: it runs once per (key, version) after a rollover, or after
8
+ # every rollover when every_deploy is set.
9
+ class Task
10
+ DEFAULT_VERSION = "1"
11
+
12
+ attr_reader :key, :class_name, :command, :version, :every_deploy, :queue_name,
13
+ :arguments, :priority, :description
14
+
15
+ def self.from_configuration(key, **options)
16
+ options = options.transform_keys(&:to_sym)
17
+ new(
18
+ key: key.to_s,
19
+ class_name: options[:class],
20
+ command: options[:command],
21
+ version: options.fetch(:version, DEFAULT_VERSION),
22
+ explicit_version: options.key?(:version),
23
+ every_deploy: options.fetch(:every_deploy, false),
24
+ queue_name: options[:queue],
25
+ arguments: options[:args].is_a?(Hash) ? [options[:args]] : Array(options[:args]),
26
+ priority: options.fetch(:priority, 0).to_i,
27
+ description: options[:description]
28
+ )
29
+ end
30
+
31
+ def initialize(key:, class_name: nil, command: nil, version: DEFAULT_VERSION, explicit_version: false,
32
+ every_deploy: false, queue_name: nil, arguments: [], priority: 0, description: nil)
33
+ @key = key
34
+ @class_name = class_name
35
+ @command = command
36
+ @version = version.to_s.strip
37
+ @explicit_version = explicit_version
38
+ @every_deploy = every_deploy
39
+ @queue_name = queue_name
40
+ @arguments = arguments || []
41
+ @priority = priority || 0
42
+ @description = description
43
+ @errors = []
44
+ @warnings = []
45
+ end
46
+
47
+ def valid?
48
+ @errors = []
49
+ @warnings = []
50
+ validate!
51
+ @errors.empty?
52
+ end
53
+
54
+ def errors
55
+ valid? unless @validated
56
+ @errors
57
+ end
58
+
59
+ def warnings
60
+ valid? unless @validated
61
+ @warnings
62
+ end
63
+
64
+ # What an arm for this task is stamped with: its version, or the
65
+ # revision it runs on for an every_deploy task.
66
+ def armed_for(revision)
67
+ every_deploy ? revision : version
68
+ end
69
+
70
+ def job_class
71
+ class_name&.safe_constantize
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ key: key, class_name: class_name, command: command, version: version, every_deploy: every_deploy,
77
+ queue_name: queue_name, arguments: arguments, priority: priority, description: description
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def validate!
84
+ @validated = true
85
+ @errors << "Key must not be blank" if key.to_s.strip.empty?
86
+ validate_target!
87
+ @errors << "Version must not be blank" if version.empty?
88
+ @errors << "every_deploy must be true or false" unless [true, false].include?(every_deploy)
89
+ return unless every_deploy == true && @explicit_version
90
+
91
+ @warnings << "every_deploy is set, so version is ignored (it runs after every rollover)"
92
+ end
93
+
94
+ def validate_target!
95
+ { class: class_name, command: command }.each do |name, value|
96
+ @errors << "#{name} must be a String" unless value.nil? || value.is_a?(String)
97
+ end
98
+ has_class = !class_name.to_s.strip.empty?
99
+ has_command = !command.to_s.strip.empty?
100
+ @errors << "Either class or command is required" unless has_class || has_command
101
+ @errors << "Declare either class or command, not both" if has_class && has_command
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/pgbus/engine.rb CHANGED
@@ -58,6 +58,23 @@ module Pgbus
58
58
  end
59
59
  end
60
60
 
61
+ # config/deploy_tasks.yml (issue #536): same shape and loader as
62
+ # recurring.yml. Explicit deploy_tasks_files replace the default file, so
63
+ # this runs after the app's config/initializers set them.
64
+ initializer "pgbus.deploy_tasks", after: :load_config_initializers do |app|
65
+ config = Pgbus.configuration
66
+ next if config.deploy_tasks
67
+
68
+ files = config.deploy_tasks_files
69
+ default_path = app.root.join("config", "deploy_tasks.yml")
70
+ tasks = if files
71
+ Pgbus::Recurring::ConfigLoader.load_all(files)
72
+ elsif default_path.exist?
73
+ Pgbus::Recurring::ConfigLoader.load(default_path)
74
+ end
75
+ config.deploy_tasks = tasks if tasks.present?
76
+ end
77
+
61
78
  initializer "pgbus.db" do
62
79
  ActiveSupport.on_load(:active_record) do
63
80
  Pgbus::BusRecord.connects_to(**Pgbus.configuration.connects_to) if Pgbus.configuration.connects_to
@@ -81,6 +98,9 @@ module Pgbus
81
98
  end
82
99
 
83
100
  initializer "pgbus.logger" do
101
+ # Rails.logger exists now: a log_format set before it did went to the
102
+ # $stdout fallback only.
103
+ Pgbus.configuration.apply_log_format!
84
104
  ActiveSupport.on_load(:after_initialize) do
85
105
  Pgbus.configuration.logger ||= Rails.logger
86
106
  end
@@ -209,5 +229,13 @@ module Pgbus
209
229
  end
210
230
  end
211
231
  end
232
+
233
+ # Last: once the app's initializers have configured pgbus, freeze the
234
+ # configuration's plain-data settings into Ractor-shareable values
235
+ # (Pgbus.finalize!, issue #527). Registered after the app's initializer
236
+ # files, so it runs after the after_initialize blocks they add.
237
+ initializer "pgbus.finalize", after: :load_config_initializers do
238
+ ActiveSupport.on_load(:after_initialize) { Pgbus.finalize! }
239
+ end
212
240
  end
213
241
  end
@@ -4,6 +4,19 @@ module Pgbus
4
4
  module EventBus
5
5
  class Handler
6
6
  class << self
7
+ # One DedupCache per handler class, built when the class is defined
8
+ # (this class body, and #inherited for every subclass), never by a
9
+ # lazy `||=` that two first deliveries could each win (issue #527).
10
+ attr_reader :dedup_cache
11
+
12
+ def inherited(subclass)
13
+ super
14
+ subclass.instance_variable_set(:@dedup_cache, DedupCache.new)
15
+ end
16
+
17
+ # Class-body macro: `idempotent!` runs while the handler class loads,
18
+ # and #idempotent? only reads the flag afterwards.
19
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
7
20
  def idempotent!
8
21
  @idempotent = true
9
22
  end
@@ -11,10 +24,7 @@ module Pgbus
11
24
  def idempotent?
12
25
  @idempotent == true
13
26
  end
14
-
15
- def dedup_cache
16
- @dedup_cache ||= DedupCache.new
17
- end
27
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
18
28
 
19
29
  # How long a pending claim may stay silent before its holder counts as
20
30
  # dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
@@ -34,6 +44,8 @@ module Pgbus
34
44
  end
35
45
  end
36
46
 
47
+ @dedup_cache = DedupCache.new
48
+
37
49
  # Outcome of the two-phase claim. `age` is how long the losing delivery
38
50
  # found the existing claim to have been silent, in seconds (nil unless
39
51
  # the claim was pending).
@@ -3,6 +3,10 @@
3
3
  module Pgbus
4
4
  module ExecutionPools
5
5
  class AsyncPool
6
+ # How long #kill waits for the killed reactor thread (and the job
7
+ # fibers it cancels) to unwind before giving up on it.
8
+ KILL_JOIN_TIMEOUT = 1
9
+
6
10
  attr_reader :capacity
7
11
 
8
12
  def initialize(capacity:, on_state_change: nil)
@@ -70,9 +74,15 @@ module Pgbus
70
74
  @reactor_thread&.join(timeout)
71
75
  end
72
76
 
77
+ # Killing the reactor thread cancels its job fibers; wait for them to
78
+ # unwind so the caller can reclaim what they held (ExecutionPools.terminate).
73
79
  def kill
74
80
  shutdown
75
- @reactor_thread&.kill
81
+ thread = @reactor_thread
82
+ return unless thread
83
+
84
+ thread.kill
85
+ join_killed(thread)
76
86
  end
77
87
 
78
88
  def metadata
@@ -86,6 +96,14 @@ module Pgbus
86
96
 
87
97
  private
88
98
 
99
+ # The reactor's own failure is already recorded (register_fatal_error);
100
+ # Thread#join would raise it again here.
101
+ def join_killed(thread)
102
+ thread.join(KILL_JOIN_TIMEOUT)
103
+ rescue StandardError => e
104
+ Pgbus.logger.debug { "[Pgbus] Async pool reactor ended with #{e.class}: #{e.message}" }
105
+ end
106
+
89
107
  def validate_dependencies!
90
108
  require "async"
91
109
  require "async/semaphore"
@@ -95,9 +113,12 @@ module Pgbus
95
113
  "Add `gem \"async\"` to your Gemfile. Original error: #{e.message}"
96
114
  end
97
115
 
116
+ # The pool's one reactor thread, owned by this pool: started once by
117
+ # #initialize, it exits when #shutdown lets the reactor drain, and
118
+ # #wait_for_termination / #kill join it.
98
119
  # rubocop:disable-next Lint/RescueException
99
120
  def start_reactor
100
- Thread.new do
121
+ Thread.new do # rubocop:disable ThreadSafety/NewThread
101
122
  Thread.current.name = "pgbus-async-reactor-#{object_id}"
102
123
  Async do |task|
103
124
  semaphore = Async::Semaphore.new(@capacity, parent: task)
@@ -5,6 +5,10 @@ require "concurrent"
5
5
  module Pgbus
6
6
  module ExecutionPools
7
7
  class ThreadPool
8
+ # How long #kill waits, in all, for the killed jobs to unwind (run their
9
+ # ensure blocks) before giving up on those still running.
10
+ KILL_JOIN_TIMEOUT = 1
11
+
8
12
  attr_reader :capacity
9
13
 
10
14
  def initialize(capacity:, on_state_change: nil)
@@ -12,14 +16,17 @@ module Pgbus
12
16
  @on_state_change = on_state_change
13
17
  @available_capacity = Concurrent::AtomicFixnum.new(capacity)
14
18
  @pool = Concurrent::FixedThreadPool.new(capacity)
19
+ @running = Concurrent::Map.new
15
20
  end
16
21
 
17
22
  def post(&block)
18
23
  @available_capacity.decrement
19
24
  begin
20
25
  @pool.post do
26
+ @running[Thread.current] = true
21
27
  block.call
22
28
  ensure
29
+ @running.delete(Thread.current)
23
30
  @available_capacity.increment
24
31
  @on_state_change&.call
25
32
  end
@@ -52,8 +59,17 @@ module Pgbus
52
59
  @pool.wait_for_termination(timeout)
53
60
  end
54
61
 
62
+ # Thread#kill only asks a thread to stop: wait for the killed jobs to
63
+ # unwind, so the caller can reclaim what they held (ExecutionPools.terminate).
64
+ # The running jobs are listed after the kill: a job that started in
65
+ # between is killed too, and a job leaves the list only once it has
66
+ # unwound, so every one still unwinding is joined. One budget for all
67
+ # of them: stuck jobs must not hold a shutdown for capacity x the timeout.
55
68
  def kill
56
69
  @pool.kill
70
+ unwinding = @running.keys
71
+ deadline = monotonic_now + KILL_JOIN_TIMEOUT
72
+ unwinding.each { |thread| thread.join([deadline - monotonic_now, 0].max) }
57
73
  end
58
74
 
59
75
  def metadata
@@ -63,6 +79,12 @@ module Pgbus
63
79
  busy: @capacity - available_capacity
64
80
  }
65
81
  end
82
+
83
+ private
84
+
85
+ def monotonic_now
86
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
87
+ end
66
88
  end
67
89
  end
68
90
  end
@@ -22,6 +22,32 @@ module Pgbus
22
22
  raise ArgumentError, "Unknown execution_mode: #{mode.inspect}. Expected one of: :threads, :async, :fiber"
23
23
  end
24
24
  end
25
+
26
+ # Stop +pool+ taking work, give the jobs already running +grace+
27
+ # seconds to finish, and kill whatever is still running after that.
28
+ # Either way, reclaim the ActiveRecord connections the jobs left checked
29
+ # out: a killed thread or fiber never checks its own back in, and the
30
+ # pool would stay that many connections short until its reaper ran.
31
+ # Also when the wait raises (an async pool re-raises a fatal reactor
32
+ # error). Returns true when every job finished within the grace (issue #527).
33
+ def terminate(pool, grace:)
34
+ pool.shutdown
35
+ finished = pool.wait_for_termination(grace) ? true : false
36
+ pool.kill unless finished
37
+ finished
38
+ ensure
39
+ reclaim_connections
40
+ end
41
+
42
+ # The caller's own connection, then every connection whose owning
43
+ # thread or fiber is dead. A no-op without ActiveRecord.
44
+ def reclaim_connections
45
+ return unless defined?(::ActiveRecord::Base)
46
+
47
+ handler = ::ActiveRecord::Base.connection_handler
48
+ handler.clear_active_connections!(:all)
49
+ handler.connection_pool_list(:all).each(&:reap)
50
+ end
25
51
  end
26
52
  end
27
53
  end
@@ -77,6 +77,7 @@ module Pgbus
77
77
  add_queue_states: "pgbus:add_queue_states",
78
78
  add_outbox: "pgbus:add_outbox",
79
79
  add_recurring: "pgbus:add_recurring",
80
+ add_deploy_tasks: "pgbus:add_deploy_tasks",
80
81
  add_failed_events_index: "pgbus:add_failed_events_index",
81
82
  add_processed_event_completion: "pgbus:add_processed_event_completion",
82
83
  add_batch_executions: "pgbus:add_batch_executions",
@@ -99,6 +100,7 @@ module Pgbus
99
100
  add_queue_states: "queue states table (pause/resume)",
100
101
  add_outbox: "outbox entries table (transactional outbox)",
101
102
  add_recurring: "recurring tasks + executions tables",
103
+ add_deploy_tasks: "deploy tasks + runs tables (one-time jobs run after a rollover)",
102
104
  add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
103
105
  add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
104
106
  add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
@@ -127,6 +129,7 @@ module Pgbus
127
129
  *queue_states_migrations,
128
130
  *outbox_migrations,
129
131
  *recurring_migrations,
132
+ *deploy_tasks_migrations,
130
133
  *failed_events_index_migrations,
131
134
  *processed_event_completion_migrations,
132
135
  *batch_executions_migrations,
@@ -215,6 +218,14 @@ module Pgbus
215
218
  [:add_recurring]
216
219
  end
217
220
 
221
+ # Same shape as recurring_migrations: plain create_table in the
222
+ # template, so the generator is queued only while a table is absent.
223
+ def deploy_tasks_migrations
224
+ return [] if table_exists?("pgbus_deploy_tasks") && table_exists?("pgbus_deploy_task_runs")
225
+
226
+ [:add_deploy_tasks]
227
+ end
228
+
218
229
  def failed_events_index_migrations
219
230
  # pgbus_failed_events is created by pgbus:install, but the
220
231
  # unique (queue_name, msg_id) index was added later via its own
@@ -28,6 +28,9 @@ module Pgbus
28
28
  # pgbus.stream.broadcast — stream broadcast (sync or deferred)
29
29
  # pgbus.outbox.publish — outbox row created
30
30
  # pgbus.recurring.enqueue — scheduler enqueued a due recurring task
31
+ # pgbus.deploy_task.enqueue — a deploy task enqueue was attempted (after a rollover or by
32
+ # Run now); payload: task, version, revision, triggered_by, plus
33
+ # :exception/:exception_object when the enqueue raised
31
34
  # pgbus.worker.recycle — worker hit a recycle threshold (reason/jobs_processed/memory_mb/lifetime_seconds)
32
35
  # pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
33
36
  # pgbus.serializer.serialize — job/event serialization