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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -147
  3. data/README.md +65 -27
  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 +98 -1
  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_batch_callback_jobs_generator.rb +4 -1
  29. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  30. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  31. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  32. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  33. data/lib/pgbus/active_job/batch_id.rb +10 -4
  34. data/lib/pgbus/batch/builder.rb +92 -0
  35. data/lib/pgbus/batch.rb +97 -22
  36. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  37. data/lib/pgbus/configuration.rb +3 -0
  38. data/lib/pgbus/deploy/revision.rb +59 -0
  39. data/lib/pgbus/deploy/rollover.rb +65 -0
  40. data/lib/pgbus/deploy/runner.rb +259 -0
  41. data/lib/pgbus/deploy/task.rb +105 -0
  42. data/lib/pgbus/engine.rb +17 -0
  43. data/lib/pgbus/generators/migration_detector.rb +12 -1
  44. data/lib/pgbus/instrumentation.rb +14 -0
  45. data/lib/pgbus/integrations/appsignal/subscriber.rb +15 -0
  46. data/lib/pgbus/mcp/server.rb +2 -1
  47. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  48. data/lib/pgbus/mcp.rb +1 -0
  49. data/lib/pgbus/metrics/subscriber.rb +12 -0
  50. data/lib/pgbus/outbox/poller.rb +1 -0
  51. data/lib/pgbus/process/consumer.rb +57 -3
  52. data/lib/pgbus/process/dispatcher.rb +18 -1
  53. data/lib/pgbus/process/heartbeat.rb +7 -2
  54. data/lib/pgbus/process/supervisor.rb +35 -3
  55. data/lib/pgbus/process/worker.rb +1 -0
  56. data/lib/pgbus/recurring/scheduler.rb +54 -20
  57. data/lib/pgbus/version.rb +1 -1
  58. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  59. data/lib/pgbus/web/data_source/list_counts.rb +2 -1
  60. data/lib/pgbus/web/data_source.rb +1 -0
  61. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  62. data/lib/pgbus.rb +11 -0
  63. data/sig/pgbus/configuration.rbs +10 -0
  64. data/sig/pgbus.rbs +9 -0
  65. 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
- :on_finish, :on_success, :on_failure,
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(on_finish: nil, on_success: nil, on_discard: nil, on_failure: nil, description: nil, properties: {},
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
- @on_finish = on_finish
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 flag, read and written under @schema_lock (issue
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
- @on_finish = nil
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
- if self.class.executions_migrated?
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
@@ -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