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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +323 -147
  3. data/README.md +44 -0
  4. data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
  5. data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
  6. data/app/helpers/pgbus/processes_helper.rb +1 -1
  7. data/app/models/pgbus/deploy_task.rb +104 -0
  8. data/app/models/pgbus/deploy_task_run.rb +45 -0
  9. data/app/views/layouts/pgbus/application.html.erb +2 -0
  10. data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
  11. data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
  12. data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
  13. data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
  14. data/config/i18n-tasks.yml +2 -0
  15. data/config/locales/da.yml +97 -0
  16. data/config/locales/de.yml +97 -0
  17. data/config/locales/en.yml +97 -0
  18. data/config/locales/es.yml +97 -0
  19. data/config/locales/fi.yml +97 -0
  20. data/config/locales/fr.yml +97 -0
  21. data/config/locales/it.yml +97 -0
  22. data/config/locales/ja.yml +97 -0
  23. data/config/locales/nb.yml +97 -0
  24. data/config/locales/nl.yml +97 -0
  25. data/config/locales/pt.yml +97 -0
  26. data/config/locales/sv.yml +97 -0
  27. data/config/routes.rb +7 -0
  28. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  29. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  30. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  31. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  32. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  33. data/lib/pgbus/configuration.rb +3 -0
  34. data/lib/pgbus/deploy/revision.rb +59 -0
  35. data/lib/pgbus/deploy/rollover.rb +65 -0
  36. data/lib/pgbus/deploy/runner.rb +259 -0
  37. data/lib/pgbus/deploy/task.rb +105 -0
  38. data/lib/pgbus/engine.rb +17 -0
  39. data/lib/pgbus/generators/migration_detector.rb +11 -0
  40. data/lib/pgbus/instrumentation.rb +3 -0
  41. data/lib/pgbus/mcp/server.rb +2 -1
  42. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  43. data/lib/pgbus/mcp.rb +1 -0
  44. data/lib/pgbus/outbox/poller.rb +1 -0
  45. data/lib/pgbus/process/consumer.rb +1 -0
  46. data/lib/pgbus/process/dispatcher.rb +18 -1
  47. data/lib/pgbus/process/heartbeat.rb +7 -2
  48. data/lib/pgbus/process/supervisor.rb +35 -3
  49. data/lib/pgbus/process/worker.rb +1 -0
  50. data/lib/pgbus/recurring/scheduler.rb +54 -20
  51. data/lib/pgbus/version.rb +1 -1
  52. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  53. data/lib/pgbus/web/data_source/list_counts.rb +2 -1
  54. data/lib/pgbus/web/data_source.rb +1 -0
  55. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  56. data/sig/pgbus/configuration.rbs +10 -0
  57. 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
@@ -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