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
|
@@ -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
|
|
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
|