pgbus 0.19.0 → 0.19.2
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 +329 -147
- data/README.md +65 -27
- data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
- 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/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 +98 -1
- 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_batch_callback_jobs_generator.rb +4 -1
- 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/batch_id.rb +10 -4
- data/lib/pgbus/batch/builder.rb +92 -0
- data/lib/pgbus/batch.rb +97 -22
- data/lib/pgbus/configuration/deploy_settings.rb +46 -0
- data/lib/pgbus/configuration.rb +3 -0
- 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 +17 -0
- data/lib/pgbus/generators/migration_detector.rb +12 -1
- data/lib/pgbus/instrumentation.rb +14 -0
- data/lib/pgbus/integrations/appsignal/subscriber.rb +15 -0
- 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 +12 -0
- data/lib/pgbus/outbox/poller.rb +1 -0
- data/lib/pgbus/process/consumer.rb +57 -3
- data/lib/pgbus/process/dispatcher.rb +18 -1
- data/lib/pgbus/process/heartbeat.rb +7 -2
- data/lib/pgbus/process/supervisor.rb +35 -3
- data/lib/pgbus/process/worker.rb +1 -0
- data/lib/pgbus/recurring/scheduler.rb +54 -20
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
- data/lib/pgbus/web/data_source/list_counts.rb +2 -1
- data/lib/pgbus/web/data_source.rb +1 -0
- data/lib/pgbus/web/deploy_task_state.rb +51 -0
- data/lib/pgbus.rb +11 -0
- data/sig/pgbus/configuration.rbs +10 -0
- data/sig/pgbus.rbs +9 -0
- metadata +21 -1
data/lib/pgbus/batch.rb
CHANGED
|
@@ -21,21 +21,57 @@ module Pgbus
|
|
|
21
21
|
LOCK_QUEUE_PREFIX = "batch:"
|
|
22
22
|
VALID_CONFLICTS = %i[reject discard log].freeze
|
|
23
23
|
|
|
24
|
+
# The callback slots, each mapped to the prefix of its pgbus_batches
|
|
25
|
+
# columns. on_complete keeps the on_finish_* columns until the 1.0 schema
|
|
26
|
+
# cleanup, so rows written before the rename still fire.
|
|
27
|
+
CALLBACK_COLUMNS = { on_complete: "on_finish", on_success: "on_success", on_failure: "on_failure" }.freeze
|
|
28
|
+
|
|
24
29
|
attr_reader :batch_id, :properties, :description,
|
|
25
|
-
:
|
|
30
|
+
:on_complete, :on_success, :on_failure,
|
|
26
31
|
:uniqueness_key, :on_conflict
|
|
27
32
|
|
|
33
|
+
# Deprecated alias of #on_complete until 1.0.
|
|
34
|
+
def on_finish = on_complete
|
|
35
|
+
|
|
28
36
|
def on_discard
|
|
29
37
|
on_failure
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
# Open a batch, yield a Pgbus::Batch::Builder, and start the batch when
|
|
41
|
+
# the block returns. Every job the block enqueues (through +b.add+ or a
|
|
42
|
+
# plain +perform_later+) joins the batch, which stays pending, and so
|
|
43
|
+
# cannot finish, until the block is done. Keywords go to Batch.new.
|
|
44
|
+
# Membership is the block's own thread or fiber (the tag lives in
|
|
45
|
+
# Thread.current): b.add raises anywhere else, and a plain perform_later
|
|
46
|
+
# from a thread or fiber the block spawns does not join the batch.
|
|
47
|
+
#
|
|
48
|
+
# Pgbus::Batch.start(description: "Import") do |b|
|
|
49
|
+
# b.on_complete ImportDoneJob
|
|
50
|
+
# b.add(*rows.map { |row| ImportRowJob.new(row.id) })
|
|
51
|
+
# end
|
|
52
|
+
def self.start(**, &block)
|
|
53
|
+
raise ArgumentError, "Pgbus.batch needs a block: Pgbus.batch { |b| b.add(...) }" unless block
|
|
54
|
+
|
|
55
|
+
batch = new(**)
|
|
56
|
+
builder = Builder.new(batch)
|
|
57
|
+
begin
|
|
58
|
+
batch.enqueue { yield builder }
|
|
59
|
+
ensure
|
|
60
|
+
builder.close
|
|
61
|
+
end
|
|
62
|
+
batch
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param on_complete [Class, ActiveJob::Base, nil] fired when every job
|
|
66
|
+
# reached a terminal state (+on_finish:+ is a deprecated alias)
|
|
32
67
|
# @param uniqueness_key [String, nil] at most one unfinished batch with
|
|
33
68
|
# this key may exist; see #enqueue for what happens to the next one
|
|
34
69
|
# @param on_conflict [Symbol] :reject (raise AlreadyRunning), :discard
|
|
35
70
|
# (skip the block silently) or :log (skip the block, warn)
|
|
36
|
-
def initialize(
|
|
37
|
-
uniqueness_key: nil, on_conflict: :reject)
|
|
71
|
+
def initialize(on_complete: nil, on_finish: nil, on_success: nil, on_discard: nil, on_failure: nil,
|
|
72
|
+
description: nil, properties: {}, uniqueness_key: nil, on_conflict: :reject)
|
|
38
73
|
raise ArgumentError, "pass on_failure: only — on_discard: is a deprecated alias" if on_discard && on_failure
|
|
74
|
+
raise ArgumentError, "pass on_complete: only — on_finish: is a deprecated alias" if on_finish && on_complete
|
|
39
75
|
unless VALID_CONFLICTS.include?(on_conflict)
|
|
40
76
|
raise ArgumentError, "on_conflict must be one of #{VALID_CONFLICTS.join(", ")}, got #{on_conflict.inspect}"
|
|
41
77
|
end
|
|
@@ -45,9 +81,10 @@ module Pgbus
|
|
|
45
81
|
"[Pgbus] Batch on_discard: is deprecated and will be removed in 1.0 — use on_failure: instead"
|
|
46
82
|
end
|
|
47
83
|
end
|
|
84
|
+
self.class.warn_on_finish_deprecated if on_finish
|
|
48
85
|
|
|
49
86
|
@batch_id = SecureRandom.uuid
|
|
50
|
-
@
|
|
87
|
+
@on_complete = on_complete || on_finish
|
|
51
88
|
@on_success = on_success
|
|
52
89
|
@on_failure = on_failure || on_discard
|
|
53
90
|
@description = description
|
|
@@ -91,6 +128,23 @@ module Pgbus
|
|
|
91
128
|
self
|
|
92
129
|
end
|
|
93
130
|
|
|
131
|
+
# Enqueue ActiveJob instances (and anything the block enqueues) into this
|
|
132
|
+
# batch: +enqueue { ActiveJob.perform_all_later(jobs) }+. Instances keep
|
|
133
|
+
# their own +.set(queue:, wait:, priority:)+ options. On a started batch
|
|
134
|
+
# this is an open-batch add, so a job running inside the batch can call
|
|
135
|
+
# +batch.add(NextJob.new(id))+. Returns the jobs; on a unique batch that
|
|
136
|
+
# another run discarded (#discarded?) nothing was sent.
|
|
137
|
+
def add(*jobs, &block)
|
|
138
|
+
jobs = Builder.job_instances(jobs)
|
|
139
|
+
return jobs if jobs.empty? && block.nil?
|
|
140
|
+
|
|
141
|
+
enqueue do
|
|
142
|
+
::ActiveJob.perform_all_later(jobs) if jobs.any?
|
|
143
|
+
yield if block
|
|
144
|
+
end
|
|
145
|
+
jobs
|
|
146
|
+
end
|
|
147
|
+
|
|
94
148
|
# --- readers on a live batch ---------------------------------------
|
|
95
149
|
|
|
96
150
|
def status = record&.status
|
|
@@ -210,11 +264,22 @@ module Pgbus
|
|
|
210
264
|
end
|
|
211
265
|
|
|
212
266
|
# Schema-probe memos (a true answer is kept for the life of the process)
|
|
213
|
-
# and the warn-once
|
|
267
|
+
# and the warn-once flags, read and written under @schema_lock (issue
|
|
214
268
|
# #527). The probes run outside it: a duplicate probe on a race is
|
|
215
269
|
# harmless, a lock held across a query is not.
|
|
216
270
|
@schema_lock = Mutex.new
|
|
217
271
|
|
|
272
|
+
# on_finish: is in most batch calls written before the rename (issue
|
|
273
|
+
# #538), so it warns once per process rather than on every batch.
|
|
274
|
+
def self.warn_on_finish_deprecated
|
|
275
|
+
first = @schema_lock.synchronize { @warned_on_finish ? false : (@warned_on_finish = true) }
|
|
276
|
+
return unless first
|
|
277
|
+
|
|
278
|
+
Pgbus.logger.warn do
|
|
279
|
+
"[Pgbus] Batch on_finish is deprecated and will be removed in 1.0 — use on_complete instead"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
218
283
|
def self.executions_migrated?
|
|
219
284
|
return true if @schema_lock.synchronize { @executions_migrated }
|
|
220
285
|
|
|
@@ -243,6 +308,7 @@ module Pgbus
|
|
|
243
308
|
@executions_migrated = nil
|
|
244
309
|
@callback_jobs_migrated = nil
|
|
245
310
|
@warned_callback_jobs_unmigrated = nil
|
|
311
|
+
@warned_on_finish = nil
|
|
246
312
|
end
|
|
247
313
|
end
|
|
248
314
|
|
|
@@ -521,7 +587,7 @@ module Pgbus
|
|
|
521
587
|
@batch_id = record.batch_id
|
|
522
588
|
@description = record.description
|
|
523
589
|
@properties = parse_properties(record.properties)
|
|
524
|
-
@
|
|
590
|
+
@on_complete = nil
|
|
525
591
|
@on_success = nil
|
|
526
592
|
@on_failure = nil
|
|
527
593
|
@uniqueness_key = nil
|
|
@@ -572,20 +638,37 @@ module Pgbus
|
|
|
572
638
|
attrs = {
|
|
573
639
|
batch_id: batch_id,
|
|
574
640
|
description: description,
|
|
575
|
-
on_finish_class: callback_class_name(on_finish),
|
|
576
|
-
on_success_class: callback_class_name(on_success),
|
|
577
641
|
properties: JSON.generate(properties),
|
|
578
642
|
status: "pending"
|
|
579
643
|
}
|
|
580
|
-
|
|
581
|
-
attrs[:on_failure_class] = callback_class_name(on_failure)
|
|
582
|
-
else
|
|
583
|
-
attrs[:on_discard_class] = callback_class_name(on_failure)
|
|
584
|
-
end
|
|
585
|
-
attrs.merge!(callback_job_attributes) if self.class.callback_jobs_migrated?
|
|
644
|
+
CALLBACK_COLUMNS.each_key { |slot| attrs.merge!(callback_attributes(slot, public_send(slot))) }
|
|
586
645
|
@record = BatchEntry.create!(attrs)
|
|
587
646
|
end
|
|
588
647
|
|
|
648
|
+
# Replace one callback on a batch that has not finished yet (the
|
|
649
|
+
# builder's setters, inside the Pgbus.batch block, while the row is
|
|
650
|
+
# still pending and so cannot fire its callbacks).
|
|
651
|
+
def write_callback(slot, callback)
|
|
652
|
+
updated = BatchEntry.where(batch_id: batch_id, status: %w[pending processing])
|
|
653
|
+
.update_all(callback_attributes(slot, callback))
|
|
654
|
+
raise AlreadyFinished, "Can't change the callbacks of an already finished batch" if updated.zero?
|
|
655
|
+
|
|
656
|
+
instance_variable_set(:"@#{slot}", callback)
|
|
657
|
+
reload
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# The pgbus_batches columns for one callback slot. Before the
|
|
661
|
+
# add_batch_executions migration the failure class lives in
|
|
662
|
+
# on_discard_class; before add_batch_callback_jobs there is no *_job
|
|
663
|
+
# column, and a configured instance degrades to its class.
|
|
664
|
+
def callback_attributes(slot, callback)
|
|
665
|
+
prefix = CALLBACK_COLUMNS.fetch(slot)
|
|
666
|
+
class_column = slot == :on_failure && !self.class.executions_migrated? ? "on_discard" : prefix
|
|
667
|
+
attrs = { "#{class_column}_class": callback_class_name(callback) }
|
|
668
|
+
attrs[:"#{prefix}_job"] = serialize_callback(callback) if self.class.callback_jobs_migrated?
|
|
669
|
+
attrs
|
|
670
|
+
end
|
|
671
|
+
|
|
589
672
|
# A callback given as a bare class keeps the legacy *_class column; a
|
|
590
673
|
# configured ActiveJob instance is serialized now (so .set options resolve
|
|
591
674
|
# at creation, matching solid_queue) into the *_job jsonb column. Before
|
|
@@ -600,14 +683,6 @@ module Pgbus
|
|
|
600
683
|
callback.class.name
|
|
601
684
|
end
|
|
602
685
|
|
|
603
|
-
def callback_job_attributes
|
|
604
|
-
{
|
|
605
|
-
on_finish_job: serialize_callback(on_finish),
|
|
606
|
-
on_success_job: serialize_callback(on_success),
|
|
607
|
-
on_failure_job: serialize_callback(on_failure)
|
|
608
|
-
}
|
|
609
|
-
end
|
|
610
|
-
|
|
611
686
|
def serialize_callback(callback)
|
|
612
687
|
return nil if callback.nil? || callback.is_a?(Class)
|
|
613
688
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
class Configuration
|
|
5
|
+
# Deploy tasks (issue #536): one-time jobs the scheduler enqueues once
|
|
6
|
+
# every live pgbus process runs the new app revision.
|
|
7
|
+
#
|
|
8
|
+
# config.app_revision = -> { File.read("REVISION").strip } # or a String
|
|
9
|
+
# config.deploy_tasks_files = ["config/deploy_tasks.yml"]
|
|
10
|
+
# config.deploy_task_run_retention = 30.days
|
|
11
|
+
module DeploySettings
|
|
12
|
+
DEFAULT_DEPLOY_TASK_RUN_RETENTION = 90 * 24 * 3600
|
|
13
|
+
|
|
14
|
+
attr_accessor :app_revision, :deploy_tasks, :deploy_tasks_files, :deploy_tasks_enabled
|
|
15
|
+
attr_reader :deploy_task_run_retention
|
|
16
|
+
|
|
17
|
+
def deploy_task_run_retention=(value)
|
|
18
|
+
@deploy_task_run_retention = coerce_duration!(value, :deploy_task_run_retention)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def initialize_deploy_settings
|
|
24
|
+
@app_revision = nil
|
|
25
|
+
@deploy_tasks = nil
|
|
26
|
+
@deploy_tasks_files = nil
|
|
27
|
+
@deploy_tasks_enabled = true
|
|
28
|
+
@deploy_task_run_retention = DEFAULT_DEPLOY_TASK_RUN_RETENTION
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_deploy_settings!
|
|
32
|
+
unless app_revision.nil? || app_revision.is_a?(String) || app_revision.respond_to?(:call)
|
|
33
|
+
raise Pgbus::ConfigurationError, "app_revision must be a String, respond to #call, or be nil"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise Pgbus::ConfigurationError, "deploy_tasks must be a Hash or nil" unless deploy_tasks.nil? || deploy_tasks.is_a?(Hash)
|
|
37
|
+
unless deploy_tasks_files.nil? || deploy_tasks_files.is_a?(Array)
|
|
38
|
+
raise Pgbus::ConfigurationError, "deploy_tasks_files must be an Array of paths or nil"
|
|
39
|
+
end
|
|
40
|
+
return if [true, false].include?(deploy_tasks_enabled)
|
|
41
|
+
|
|
42
|
+
raise Pgbus::ConfigurationError, "deploy_tasks_enabled must be true or false"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Pgbus
|
|
|
6
6
|
class Configuration
|
|
7
7
|
include PresenceSettings
|
|
8
8
|
include StreamAuthorization
|
|
9
|
+
include DeploySettings
|
|
9
10
|
|
|
10
11
|
# Connection settings
|
|
11
12
|
attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
|
|
@@ -363,6 +364,7 @@ module Pgbus
|
|
|
363
364
|
@recurring_tasks_files = nil
|
|
364
365
|
@recurring_enabled = true
|
|
365
366
|
@recurring_execution_retention = 7 * 24 * 3600 # 7 days
|
|
367
|
+
initialize_deploy_settings
|
|
366
368
|
|
|
367
369
|
@zombie_detection = true
|
|
368
370
|
|
|
@@ -944,6 +946,7 @@ module Pgbus
|
|
|
944
946
|
validate_streams!
|
|
945
947
|
validate_metrics_backend!
|
|
946
948
|
validate_fair_share!
|
|
949
|
+
validate_deploy_settings!
|
|
947
950
|
|
|
948
951
|
self
|
|
949
952
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Deploy
|
|
5
|
+
# The app revision this process runs (issue #536). Every heartbeat row
|
|
6
|
+
# carries it in metadata["revision"]; the deploy-task rollover gate
|
|
7
|
+
# compares those against the scheduler's own.
|
|
8
|
+
#
|
|
9
|
+
# First match wins, blank counts as absent:
|
|
10
|
+
# config.app_revision (String or callable) → PGBUS_REVISION → DASH_VERSION
|
|
11
|
+
# → KAMAL_VERSION → the REVISION file at Rails.root (Capistrano) → nil
|
|
12
|
+
#
|
|
13
|
+
# Computed per call, never memoized: it is read once per process
|
|
14
|
+
# registration, and a module-level memo would be unguarded shared state.
|
|
15
|
+
module Revision
|
|
16
|
+
ENV_KEYS = %w[PGBUS_REVISION DASH_VERSION KAMAL_VERSION].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def current(config = Pgbus.configuration)
|
|
21
|
+
from_config(config) || from_env || from_file
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def from_config(config)
|
|
25
|
+
value = config.app_revision
|
|
26
|
+
value = value.call if value.respond_to?(:call)
|
|
27
|
+
presence(value)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Pgbus.logger.warn { "[Pgbus] app_revision raised #{e.class}: #{e.message}; trying the environment" }
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def from_env
|
|
34
|
+
ENV_KEYS.each do |key|
|
|
35
|
+
value = presence(ENV.fetch(key, nil))
|
|
36
|
+
return value if value
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def from_file
|
|
42
|
+
return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
43
|
+
|
|
44
|
+
path = Rails.root.join("REVISION")
|
|
45
|
+
File.exist?(path) ? presence(File.read(path)) : nil
|
|
46
|
+
rescue SystemCallError, IOError => e
|
|
47
|
+
# Read while every heartbeat initializes: an unreadable optional file
|
|
48
|
+
# must not stop a process from starting.
|
|
49
|
+
Pgbus.logger.warn { "[Pgbus] Could not read #{path}: #{e.class}: #{e.message}" }
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def presence(value)
|
|
54
|
+
string = value.to_s.strip
|
|
55
|
+
string.empty? ? nil : string
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Deploy
|
|
7
|
+
# Has the fleet rolled over to +revision+? (issue #536). A pure read of
|
|
8
|
+
# pgbus_processes: live rows (heartbeat within Heartbeat::ALIVE_THRESHOLD)
|
|
9
|
+
# whose metadata["revision"] differs from +revision+ are lagging (a missing
|
|
10
|
+
# revision is a pre-upgrade process, so it lags too), and at least one live
|
|
11
|
+
# worker on +revision+ must exist. The scheduler and the dashboard both
|
|
12
|
+
# consume the Status, so the page prints the reason the scheduler acts on.
|
|
13
|
+
module Rollover
|
|
14
|
+
# reason – :ready, :no_revision, :lagging or :no_workers.
|
|
15
|
+
# lagging – [{ kind:, hostname:, pid:, revision: }].
|
|
16
|
+
# live_by_revision – { revision => live process count }, nil for rows without one.
|
|
17
|
+
Status = Data.define(:revision, :lagging, :workers_live, :reason, :live_by_revision) do
|
|
18
|
+
def rolled_over?
|
|
19
|
+
reason == :ready
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def check(revision:, now: Time.current)
|
|
26
|
+
cutoff = now - Process::Heartbeat::ALIVE_THRESHOLD
|
|
27
|
+
rows = ProcessEntry.where(last_heartbeat_at: cutoff..).pluck(:kind, :hostname, :pid, :metadata)
|
|
28
|
+
evaluate(rows.map { |kind, hostname, pid, metadata| { kind:, hostname:, pid:, metadata: } }, revision: revision)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# [live, revision] of the newest live scheduler row: the revision the
|
|
32
|
+
# dashboard judges the rollover against (the one the scheduler acts on).
|
|
33
|
+
def scheduler_revision(now: Time.current)
|
|
34
|
+
row = ProcessEntry.where(kind: "scheduler", last_heartbeat_at: (now - Process::Heartbeat::ALIVE_THRESHOLD)..)
|
|
35
|
+
.order(created_at: :desc, id: :desc).first
|
|
36
|
+
row ? [true, revision_of(row.metadata)] : [false, nil]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# rows – live process rows as { kind:, hostname:, pid:, metadata: }.
|
|
40
|
+
def evaluate(rows, revision:)
|
|
41
|
+
live = rows.map { |r| { kind: r[:kind], hostname: r[:hostname], pid: r[:pid], revision: revision_of(r[:metadata]) } }
|
|
42
|
+
lagging = live.reject { |r| revision && r[:revision] == revision }
|
|
43
|
+
workers_live = live.count { |r| r[:kind] == "worker" && revision && r[:revision] == revision }
|
|
44
|
+
|
|
45
|
+
Status.new(revision: revision, lagging: revision ? lagging : [], workers_live: workers_live,
|
|
46
|
+
reason: reason_for(revision, lagging, workers_live), live_by_revision: live.map { |r| r[:revision] }.tally)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reason_for(revision, lagging, workers_live)
|
|
50
|
+
return :no_revision if revision.nil?
|
|
51
|
+
return :lagging if lagging.any?
|
|
52
|
+
return :no_workers if workers_live.zero?
|
|
53
|
+
|
|
54
|
+
:ready
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def revision_of(metadata)
|
|
58
|
+
metadata = JSON.parse(metadata) if metadata.is_a?(String)
|
|
59
|
+
metadata.is_a?(Hash) ? metadata["revision"] : nil
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -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
|