pgbus 0.19.0 → 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 +323 -147
- data/README.md +44 -0
- 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 +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/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 +11 -0
- data/lib/pgbus/instrumentation.rb +3 -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/outbox/poller.rb +1 -0
- data/lib/pgbus/process/consumer.rb +1 -0
- 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/sig/pgbus/configuration.rbs +10 -0
- metadata +20 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "migration_path"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Generators
|
|
9
|
+
class AddDeployTasksGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationPath
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Add deploy tasks tables (one-time jobs run after a rollover) to an existing Pgbus installation"
|
|
16
|
+
|
|
17
|
+
class_option :database,
|
|
18
|
+
type: :string,
|
|
19
|
+
default: nil,
|
|
20
|
+
desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
|
|
21
|
+
|
|
22
|
+
def create_migration_file
|
|
23
|
+
migration_template "add_deploy_tasks_tables.rb.erb",
|
|
24
|
+
File.join(pgbus_migrate_path, "add_pgbus_deploy_tasks_tables.rb")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_deploy_tasks_config
|
|
28
|
+
template "deploy_tasks.yml.erb", "config/deploy_tasks.yml"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def display_post_install
|
|
32
|
+
say ""
|
|
33
|
+
say "Pgbus deploy tasks installed!", :green
|
|
34
|
+
say ""
|
|
35
|
+
say "Next steps:"
|
|
36
|
+
say " 1. Run: rails db:migrate#{migrate_command_suffix}"
|
|
37
|
+
say " 2. Edit config/deploy_tasks.yml to declare your one-time tasks"
|
|
38
|
+
say " 3. Make sure every pgbus process knows the app revision (config.app_revision,"
|
|
39
|
+
say " PGBUS_REVISION, DASH_VERSION, KAMAL_VERSION or a REVISION file)"
|
|
40
|
+
say " 4. Restart pgbus: bin/pgbus start"
|
|
41
|
+
say ""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def migration_version
|
|
47
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class AddPgbusDeployTasksTables < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
# if_not_exists throughout: pgbus:update queues this migration when either
|
|
3
|
+
# table is missing, so a half-present schema must not fail it.
|
|
4
|
+
def change
|
|
5
|
+
create_table :pgbus_deploy_tasks, if_not_exists: true do |t|
|
|
6
|
+
t.string :key, null: false
|
|
7
|
+
t.string :class_name
|
|
8
|
+
t.string :command, limit: 2048
|
|
9
|
+
t.text :arguments
|
|
10
|
+
t.string :queue_name
|
|
11
|
+
t.integer :priority, default: 0
|
|
12
|
+
t.string :version, null: false, default: "1"
|
|
13
|
+
t.boolean :every_deploy, null: false, default: false
|
|
14
|
+
t.boolean :static, null: false, default: true
|
|
15
|
+
t.boolean :enabled, null: false, default: true
|
|
16
|
+
t.text :description
|
|
17
|
+
t.datetime :armed_at
|
|
18
|
+
t.string :armed_for
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :pgbus_deploy_tasks, :key,
|
|
23
|
+
unique: true, name: "idx_pgbus_deploy_tasks_key", if_not_exists: true
|
|
24
|
+
add_index :pgbus_deploy_tasks, :armed_at,
|
|
25
|
+
where: "armed_at IS NOT NULL", name: "idx_pgbus_deploy_tasks_armed", if_not_exists: true
|
|
26
|
+
|
|
27
|
+
create_table :pgbus_deploy_task_runs, if_not_exists: true do |t|
|
|
28
|
+
t.string :task_key, null: false
|
|
29
|
+
t.string :version, null: false
|
|
30
|
+
t.string :revision
|
|
31
|
+
t.string :triggered_by, null: false
|
|
32
|
+
t.string :queue_name
|
|
33
|
+
t.bigint :msg_id
|
|
34
|
+
t.datetime :run_at, null: false
|
|
35
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :pgbus_deploy_task_runs, [:task_key, :run_at],
|
|
39
|
+
name: "idx_pgbus_deploy_task_runs_task", if_not_exists: true
|
|
40
|
+
add_index :pgbus_deploy_task_runs, :run_at,
|
|
41
|
+
name: "idx_pgbus_deploy_task_runs_cleanup", if_not_exists: true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Pgbus Deploy Tasks Configuration
|
|
2
|
+
#
|
|
3
|
+
# One-time jobs that run once the whole fleet has rolled over to a new app
|
|
4
|
+
# revision: a backfill, a data fix, a cache warm. Same shape as
|
|
5
|
+
# config/recurring.yml, without schedule:. Supports environment-scoped or
|
|
6
|
+
# flat configuration.
|
|
7
|
+
#
|
|
8
|
+
# The scheduler enqueues a task only when every live pgbus process reports
|
|
9
|
+
# the scheduler's own app revision and at least one worker on it is alive,
|
|
10
|
+
# so a task never runs on old code during the deploy overlap window. The
|
|
11
|
+
# revision comes from config.app_revision, then PGBUS_REVISION, DASH_VERSION,
|
|
12
|
+
# KAMAL_VERSION, then the REVISION file at Rails.root. Without one, deploy
|
|
13
|
+
# tasks wait (the dashboard says so) and only "Run now" enqueues them.
|
|
14
|
+
#
|
|
15
|
+
# Format:
|
|
16
|
+
# task_key:
|
|
17
|
+
# class: JobClassName # ActiveJob class to run (or use command:)
|
|
18
|
+
# version: 1 # Runs once per version; bump it to run again after the next deploy
|
|
19
|
+
# every_deploy: false # true: run after every rollover instead (version is ignored)
|
|
20
|
+
# queue: default # Optional: queue name (default: pgbus default queue)
|
|
21
|
+
# args: [arg1, arg2] # Optional: job arguments
|
|
22
|
+
# priority: 0 # Optional: numeric priority
|
|
23
|
+
# description: "text" # Optional: human-readable description
|
|
24
|
+
#
|
|
25
|
+
# Command-based tasks (no job class needed):
|
|
26
|
+
# warm_fragment_caches:
|
|
27
|
+
# command: "FragmentCacheWarmer.run"
|
|
28
|
+
# every_deploy: true
|
|
29
|
+
#
|
|
30
|
+
# Examples:
|
|
31
|
+
#
|
|
32
|
+
# production:
|
|
33
|
+
# backfill_order_totals:
|
|
34
|
+
# class: BackfillOrderTotalsJob
|
|
35
|
+
# version: 2
|
|
36
|
+
# queue: maintenance
|
|
37
|
+
# args: [{ batch_size: 500 }]
|
|
38
|
+
# description: Recompute totals after the currency migration
|
|
39
|
+
#
|
|
40
|
+
# warm_fragment_caches:
|
|
41
|
+
# command: "FragmentCacheWarmer.run"
|
|
42
|
+
# every_deploy: true
|
|
@@ -170,6 +170,47 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
170
170
|
add_index :pgbus_recurring_executions, :run_at,
|
|
171
171
|
name: "idx_pgbus_recurring_executions_cleanup"
|
|
172
172
|
|
|
173
|
+
# Deploy tasks (synced from config/deploy_tasks.yml): one-time jobs the
|
|
174
|
+
# scheduler enqueues once the fleet has rolled over to a new revision.
|
|
175
|
+
# armed_at/armed_for is the compare-and-set claim; runs are the history.
|
|
176
|
+
create_table :pgbus_deploy_tasks do |t|
|
|
177
|
+
t.string :key, null: false
|
|
178
|
+
t.string :class_name
|
|
179
|
+
t.string :command, limit: 2048
|
|
180
|
+
t.text :arguments
|
|
181
|
+
t.string :queue_name
|
|
182
|
+
t.integer :priority, default: 0
|
|
183
|
+
t.string :version, null: false, default: "1"
|
|
184
|
+
t.boolean :every_deploy, null: false, default: false
|
|
185
|
+
t.boolean :static, null: false, default: true
|
|
186
|
+
t.boolean :enabled, null: false, default: true
|
|
187
|
+
t.text :description
|
|
188
|
+
t.datetime :armed_at
|
|
189
|
+
t.string :armed_for
|
|
190
|
+
t.timestamps
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
add_index :pgbus_deploy_tasks, :key,
|
|
194
|
+
unique: true, name: "idx_pgbus_deploy_tasks_key"
|
|
195
|
+
add_index :pgbus_deploy_tasks, :armed_at,
|
|
196
|
+
where: "armed_at IS NOT NULL", name: "idx_pgbus_deploy_tasks_armed"
|
|
197
|
+
|
|
198
|
+
create_table :pgbus_deploy_task_runs do |t|
|
|
199
|
+
t.string :task_key, null: false
|
|
200
|
+
t.string :version, null: false
|
|
201
|
+
t.string :revision
|
|
202
|
+
t.string :triggered_by, null: false
|
|
203
|
+
t.string :queue_name
|
|
204
|
+
t.bigint :msg_id
|
|
205
|
+
t.datetime :run_at, null: false
|
|
206
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
add_index :pgbus_deploy_task_runs, [:task_key, :run_at],
|
|
210
|
+
name: "idx_pgbus_deploy_task_runs_task"
|
|
211
|
+
add_index :pgbus_deploy_task_runs, :run_at,
|
|
212
|
+
name: "idx_pgbus_deploy_task_runs_cleanup"
|
|
213
|
+
|
|
173
214
|
# Stream queue registry — records which PGMQ queues back streams (vs jobs).
|
|
174
215
|
# Stream and job queues share the pgbus_ namespace, so maintenance
|
|
175
216
|
# (per-stream retention, orphan sweep) and wildcard workers rely on this
|
|
@@ -204,6 +245,8 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
204
245
|
execute "SELECT pgmq.drop_queue('pgbus_default')"
|
|
205
246
|
|
|
206
247
|
drop_table :pgbus_stream_queues
|
|
248
|
+
drop_table :pgbus_deploy_task_runs
|
|
249
|
+
drop_table :pgbus_deploy_tasks
|
|
207
250
|
drop_table :pgbus_recurring_executions
|
|
208
251
|
drop_table :pgbus_recurring_tasks
|
|
209
252
|
drop_table :pgbus_batch_executions
|
|
@@ -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
|