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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Deploy
5
+ # One declared deploy task (issue #536): an entry of
6
+ # config/deploy_tasks.yml. The sibling of Recurring::Task without a
7
+ # schedule: it runs once per (key, version) after a rollover, or after
8
+ # every rollover when every_deploy is set.
9
+ class Task
10
+ DEFAULT_VERSION = "1"
11
+
12
+ attr_reader :key, :class_name, :command, :version, :every_deploy, :queue_name,
13
+ :arguments, :priority, :description
14
+
15
+ def self.from_configuration(key, **options)
16
+ options = options.transform_keys(&:to_sym)
17
+ new(
18
+ key: key.to_s,
19
+ class_name: options[:class],
20
+ command: options[:command],
21
+ version: options.fetch(:version, DEFAULT_VERSION),
22
+ explicit_version: options.key?(:version),
23
+ every_deploy: options.fetch(:every_deploy, false),
24
+ queue_name: options[:queue],
25
+ arguments: options[:args].is_a?(Hash) ? [options[:args]] : Array(options[:args]),
26
+ priority: options.fetch(:priority, 0).to_i,
27
+ description: options[:description]
28
+ )
29
+ end
30
+
31
+ def initialize(key:, class_name: nil, command: nil, version: DEFAULT_VERSION, explicit_version: false,
32
+ every_deploy: false, queue_name: nil, arguments: [], priority: 0, description: nil)
33
+ @key = key
34
+ @class_name = class_name
35
+ @command = command
36
+ @version = version.to_s.strip
37
+ @explicit_version = explicit_version
38
+ @every_deploy = every_deploy
39
+ @queue_name = queue_name
40
+ @arguments = arguments || []
41
+ @priority = priority || 0
42
+ @description = description
43
+ @errors = []
44
+ @warnings = []
45
+ end
46
+
47
+ def valid?
48
+ @errors = []
49
+ @warnings = []
50
+ validate!
51
+ @errors.empty?
52
+ end
53
+
54
+ def errors
55
+ valid? unless @validated
56
+ @errors
57
+ end
58
+
59
+ def warnings
60
+ valid? unless @validated
61
+ @warnings
62
+ end
63
+
64
+ # What an arm for this task is stamped with: its version, or the
65
+ # revision it runs on for an every_deploy task.
66
+ def armed_for(revision)
67
+ every_deploy ? revision : version
68
+ end
69
+
70
+ def job_class
71
+ class_name&.safe_constantize
72
+ end
73
+
74
+ def to_h
75
+ {
76
+ key: key, class_name: class_name, command: command, version: version, every_deploy: every_deploy,
77
+ queue_name: queue_name, arguments: arguments, priority: priority, description: description
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def validate!
84
+ @validated = true
85
+ @errors << "Key must not be blank" if key.to_s.strip.empty?
86
+ validate_target!
87
+ @errors << "Version must not be blank" if version.empty?
88
+ @errors << "every_deploy must be true or false" unless [true, false].include?(every_deploy)
89
+ return unless every_deploy == true && @explicit_version
90
+
91
+ @warnings << "every_deploy is set, so version is ignored (it runs after every rollover)"
92
+ end
93
+
94
+ def validate_target!
95
+ { class: class_name, command: command }.each do |name, value|
96
+ @errors << "#{name} must be a String" unless value.nil? || value.is_a?(String)
97
+ end
98
+ has_class = !class_name.to_s.strip.empty?
99
+ has_command = !command.to_s.strip.empty?
100
+ @errors << "Either class or command is required" unless has_class || has_command
101
+ @errors << "Declare either class or command, not both" if has_class && has_command
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/pgbus/engine.rb CHANGED
@@ -58,6 +58,23 @@ module Pgbus
58
58
  end
59
59
  end
60
60
 
61
+ # config/deploy_tasks.yml (issue #536): same shape and loader as
62
+ # recurring.yml. Explicit deploy_tasks_files replace the default file, so
63
+ # this runs after the app's config/initializers set them.
64
+ initializer "pgbus.deploy_tasks", after: :load_config_initializers do |app|
65
+ config = Pgbus.configuration
66
+ next if config.deploy_tasks
67
+
68
+ files = config.deploy_tasks_files
69
+ default_path = app.root.join("config", "deploy_tasks.yml")
70
+ tasks = if files
71
+ Pgbus::Recurring::ConfigLoader.load_all(files)
72
+ elsif default_path.exist?
73
+ Pgbus::Recurring::ConfigLoader.load(default_path)
74
+ end
75
+ config.deploy_tasks = tasks if tasks.present?
76
+ end
77
+
61
78
  initializer "pgbus.db" do
62
79
  ActiveSupport.on_load(:active_record) do
63
80
  Pgbus::BusRecord.connects_to(**Pgbus.configuration.connects_to) if Pgbus.configuration.connects_to
@@ -77,6 +77,7 @@ module Pgbus
77
77
  add_queue_states: "pgbus:add_queue_states",
78
78
  add_outbox: "pgbus:add_outbox",
79
79
  add_recurring: "pgbus:add_recurring",
80
+ add_deploy_tasks: "pgbus:add_deploy_tasks",
80
81
  add_failed_events_index: "pgbus:add_failed_events_index",
81
82
  add_processed_event_completion: "pgbus:add_processed_event_completion",
82
83
  add_batch_executions: "pgbus:add_batch_executions",
@@ -99,6 +100,7 @@ module Pgbus
99
100
  add_queue_states: "queue states table (pause/resume)",
100
101
  add_outbox: "outbox entries table (transactional outbox)",
101
102
  add_recurring: "recurring tasks + executions tables",
103
+ add_deploy_tasks: "deploy tasks + runs tables (one-time jobs run after a rollover)",
102
104
  add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
103
105
  add_processed_event_completion: "completed_at on pgbus_processed_events (two-phase idempotency claim)",
104
106
  add_batch_executions: "batch execution-row tracking (self-healing completion, on_failure rename)",
@@ -127,6 +129,7 @@ module Pgbus
127
129
  *queue_states_migrations,
128
130
  *outbox_migrations,
129
131
  *recurring_migrations,
132
+ *deploy_tasks_migrations,
130
133
  *failed_events_index_migrations,
131
134
  *processed_event_completion_migrations,
132
135
  *batch_executions_migrations,
@@ -215,6 +218,14 @@ module Pgbus
215
218
  [:add_recurring]
216
219
  end
217
220
 
221
+ # Same shape as recurring_migrations: plain create_table in the
222
+ # template, so the generator is queued only while a table is absent.
223
+ def deploy_tasks_migrations
224
+ return [] if table_exists?("pgbus_deploy_tasks") && table_exists?("pgbus_deploy_task_runs")
225
+
226
+ [:add_deploy_tasks]
227
+ end
228
+
218
229
  def failed_events_index_migrations
219
230
  # pgbus_failed_events is created by pgbus:install, but the
220
231
  # unique (queue_name, msg_id) index was added later via its own
@@ -28,6 +28,9 @@ module Pgbus
28
28
  # pgbus.stream.broadcast — stream broadcast (sync or deferred)
29
29
  # pgbus.outbox.publish — outbox row created
30
30
  # pgbus.recurring.enqueue — scheduler enqueued a due recurring task
31
+ # pgbus.deploy_task.enqueue — a deploy task enqueue was attempted (after a rollover or by
32
+ # Run now); payload: task, version, revision, triggered_by, plus
33
+ # :exception/:exception_object when the enqueue raised
31
34
  # pgbus.worker.recycle — worker hit a recycle threshold (reason/jobs_processed/memory_mb/lifetime_seconds)
32
35
  # pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
33
36
  # pgbus.serializer.serialize — job/event serialization
@@ -26,7 +26,8 @@ module Pgbus
26
26
  Tools::ConcurrencyTool,
27
27
  Tools::ThroughputTool,
28
28
  Tools::StatsTool,
29
- Tools::RecurringTool
29
+ Tools::RecurringTool,
30
+ Tools::DeployTasksTool
30
31
  ].freeze
31
32
 
32
33
  INSTRUCTIONS = <<~TEXT
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Deploy tasks with their state and the rollover the scheduler waits on.
7
+ # Maps to DataSource#deploy_tasks and #deploy_rollover. Job arguments are
8
+ # not part of the list payload (and are dropped here if a data source
9
+ # ever adds them), so the tool never carries them.
10
+ class DeployTasksTool < BaseTool
11
+ tool_name "pgbus_deploy_tasks"
12
+ title "Pgbus Deploy Tasks"
13
+ description <<~DESC
14
+ Deploy tasks: one-time jobs the scheduler enqueues once every live pgbus process runs the new app revision. Lists each task's state (disabled, waiting, due, ran, never) and the rollover status the scheduler waits on.
15
+ Returns every task unless page or per_page is given; then it is
16
+ paginated (per_page defaults to 100, capped at 100). The response
17
+ carries page, per_page, total (capped at 10000 when paginated; the
18
+ exact row count when not) and has_more; per_page is null when
19
+ unpaginated.
20
+ DESC
21
+
22
+ MAX_PER_PAGE = 100
23
+ MAX_PAGE = 1_000
24
+
25
+ input_schema(
26
+ properties: {
27
+ page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
28
+ per_page: { type: "integer", description: "Rows per page (default 100 once paginating, max 100).", minimum: 1 }
29
+ },
30
+ required: []
31
+ )
32
+
33
+ def self.call(page: nil, per_page: nil, server_context: nil)
34
+ data_source = data_source_from(server_context)
35
+ return unpaged(data_source) if page.nil? && per_page.nil?
36
+
37
+ per_page = (per_page || MAX_PER_PAGE).to_i.clamp(1, MAX_PER_PAGE)
38
+ page = (page || 1).to_i.clamp(1, MAX_PAGE)
39
+ tasks = data_source.deploy_tasks(page: page, per_page: per_page)
40
+ count = data_source.list_count(:deploy_tasks)
41
+
42
+ json_response(
43
+ payload(data_source, tasks).merge(
44
+ page: page, per_page: per_page, total: count.total,
45
+ has_more: more_pages?(count, page: page, per_page: per_page, shown: tasks.size)
46
+ )
47
+ )
48
+ end
49
+
50
+ def self.unpaged(data_source)
51
+ tasks = data_source.deploy_tasks
52
+ json_response(
53
+ payload(data_source, tasks).merge(page: 1, per_page: nil, total: tasks.size, has_more: false)
54
+ )
55
+ end
56
+ private_class_method :unpaged
57
+
58
+ def self.payload(data_source, tasks)
59
+ rollover = data_source.deploy_rollover
60
+ { rollover: serialize(rollover), tasks: tasks.map { |row| task_hash(row, rollover) } }
61
+ end
62
+ private_class_method :payload
63
+
64
+ def self.task_hash(row, rollover)
65
+ state = Pgbus::Web::DeployTaskState.present(row, rollover)
66
+ serialize(row.except(:arguments)).merge(
67
+ "state" => state.state, "reason" => state.reason_key, "reason_args" => serialize(state.reason_args)
68
+ )
69
+ end
70
+ private_class_method :task_hash
71
+
72
+ # Times as ISO 8601 UTC, symbols as strings, string keys throughout.
73
+ def self.serialize(value)
74
+ case value
75
+ when Hash then value.to_h { |key, val| [key.to_s, serialize(val)] }
76
+ when Array then value.map { |val| serialize(val) }
77
+ when Symbol then value.to_s
78
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601
79
+ else value
80
+ end
81
+ end
82
+ private_class_method :serialize
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/pgbus/mcp.rb CHANGED
@@ -28,6 +28,7 @@ module Pgbus
28
28
  pgbus/mcp/tools/throughput_tool
29
29
  pgbus/mcp/tools/stats_tool
30
30
  pgbus/mcp/tools/recurring_tool
31
+ pgbus/mcp/tools/deploy_tasks_tool
31
32
  pgbus/mcp/server
32
33
  pgbus/mcp/runner
33
34
  pgbus/mcp/rack_app
@@ -139,6 +139,7 @@ module Pgbus
139
139
  def start_heartbeat
140
140
  @heartbeat = Process::Heartbeat.new(
141
141
  kind: "outbox_poller",
142
+ config: @config,
142
143
  metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid }
143
144
  )
144
145
  @heartbeat.start
@@ -615,6 +615,7 @@ module Pgbus
615
615
  def start_heartbeat
616
616
  @heartbeat = Heartbeat.new(
617
617
  kind: "consumer",
618
+ config: @config,
618
619
  metadata: {
619
620
  topics: topics, threads: threads, pid: ::Process.pid, execution_mode: @execution_mode,
620
621
  started_at: @started_at.to_f, supervisor_pid: ::Process.ppid
@@ -13,6 +13,7 @@ module Pgbus
13
13
  CONCURRENCY_INTERVAL = 300 # Run concurrency cleanup every 5 minutes
14
14
  BATCH_CLEANUP_INTERVAL = 3600 # Run batch cleanup every hour
15
15
  RECURRING_CLEANUP_INTERVAL = 3600 # Run recurring execution cleanup every hour
16
+ DEPLOY_RUN_CLEANUP_INTERVAL = 3600 # Run deploy task run cleanup every hour
16
17
  ARCHIVE_COMPACTION_INTERVAL = 3600 # Run archive compaction every hour
17
18
  OUTBOX_CLEANUP_INTERVAL = 3600 # Run outbox cleanup every hour
18
19
  JOB_LOCK_CLEANUP_INTERVAL = 300 # Run job lock cleanup every 5 minutes
@@ -52,7 +53,8 @@ module Pgbus
52
53
  MAINTENANCE_TIMESTAMPS = %i[
53
54
  @last_cleanup_at @last_reap_at @last_concurrency_at @last_batch_cleanup_at
54
55
  @last_batch_sweep_at
55
- @last_recurring_cleanup_at @last_archive_compaction_at @last_stream_archive_compaction_at
56
+ @last_recurring_cleanup_at @last_deploy_run_cleanup_at @last_archive_compaction_at
57
+ @last_stream_archive_compaction_at
56
58
  @last_outbox_cleanup_at @last_job_lock_cleanup_at @last_stats_cleanup_at
57
59
  @last_orphan_stream_sweep_at @last_table_maintenance_at
58
60
  ].freeze
@@ -236,6 +238,7 @@ module Pgbus
236
238
  results << run_if_due(now, :@last_batch_cleanup_at, BATCH_CLEANUP_INTERVAL) { cleanup_batches }
237
239
  results << run_if_due(now, :@last_batch_sweep_at, config.batch_sweep_interval) { sweep_stalled_batches }
238
240
  results << run_if_due(now, :@last_recurring_cleanup_at, RECURRING_CLEANUP_INTERVAL) { cleanup_recurring_executions }
241
+ results << run_if_due(now, :@last_deploy_run_cleanup_at, DEPLOY_RUN_CLEANUP_INTERVAL) { cleanup_deploy_task_runs }
239
242
  results << run_if_due(now, :@last_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { compact_archives }
240
243
  results << run_if_due(now, :@last_stream_archive_compaction_at, ARCHIVE_COMPACTION_INTERVAL) { prune_stream_archives }
241
244
  results << run_if_due(now, :@last_outbox_cleanup_at, OUTBOX_CLEANUP_INTERVAL) { cleanup_outbox }
@@ -681,6 +684,19 @@ module Pgbus
681
684
  e
682
685
  end
683
686
 
687
+ # Guarded on the table: deploy tasks default on, and an install that has
688
+ # not run pgbus:add_deploy_tasks must not warn every hour.
689
+ def cleanup_deploy_task_runs
690
+ retention = config.deploy_task_run_retention
691
+ return unless retention&.positive? && DeployTaskRun.table_exists?
692
+
693
+ deleted = DeployTaskRun.prunable_before(Time.current - retention).delete_all
694
+ Pgbus.logger.debug { "[Pgbus] Cleaned up #{deleted} old deploy task runs" } if deleted.positive?
695
+ rescue StandardError => e
696
+ log_maintenance_failure("Deploy task run cleanup", e)
697
+ e
698
+ end
699
+
684
700
  def monotonic_now
685
701
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
686
702
  end
@@ -697,6 +713,7 @@ module Pgbus
697
713
  def start_heartbeat
698
714
  @heartbeat = Heartbeat.new(
699
715
  kind: "dispatcher",
716
+ config: @config,
700
717
  metadata: { pid: ::Process.pid, started_at: Time.now.to_f, supervisor_pid: ::Process.ppid },
701
718
  loop_tick_supplier: -> { @loop_tick_at.get }
702
719
  )
@@ -11,9 +11,14 @@ module Pgbus
11
11
 
12
12
  attr_reader :process_entry
13
13
 
14
- def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil)
14
+ # config: the owning process's configuration, for the app revision.
15
+ def initialize(kind:, metadata: {}, on_beat: nil, loop_tick_supplier: nil, metadata_supplier: nil,
16
+ config: Pgbus.configuration)
15
17
  @kind = kind
16
- @metadata = metadata
18
+ # Every kind's row carries the app revision from its first
19
+ # registration on, nil included: the deploy-task rollover gate reads
20
+ # it (issue #536), and beat_metadata dups this hash so it survives.
21
+ @metadata = metadata.merge(revision: Deploy::Revision.current(config))
17
22
  @on_beat = on_beat
18
23
  @loop_tick_supplier = loop_tick_supplier
19
24
  @metadata_supplier = metadata_supplier
@@ -425,9 +425,12 @@ module Pgbus
425
425
  ErrorReporter.report(e, { action: "fork_dispatcher" })
426
426
  end
427
427
 
428
+ # The scheduler runs recurring tasks and deploy tasks (issue #536);
429
+ # either one being enabled and configured forks it.
428
430
  def boot_scheduler
429
- return unless config.recurring_enabled
430
- return unless recurring_tasks_configured?
431
+ recurring = config.recurring_enabled && recurring_tasks_configured?
432
+ deploy = config.deploy_tasks_enabled && deploy_tasks_configured?
433
+ return unless recurring || deploy
431
434
 
432
435
  fork_scheduler
433
436
  end
@@ -438,7 +441,8 @@ module Pgbus
438
441
  restore_signals
439
442
  setup_child_process
440
443
  load_rails_app
441
- load_recurring_config
444
+ load_recurring_config if config.recurring_enabled
445
+ load_deploy_tasks_config if config.deploy_tasks_enabled
442
446
  bootstrap_queues!
443
447
  scheduler = Recurring::Scheduler.new(config: config)
444
448
  scheduler.run
@@ -488,6 +492,33 @@ module Pgbus
488
492
  config.recurring_tasks = Recurring::ConfigLoader.load(path)
489
493
  end
490
494
 
495
+ def deploy_tasks_configured?
496
+ return true if config.deploy_tasks&.any?
497
+ # Explicit files replace the default file, as in load_deploy_tasks_config.
498
+ return config.deploy_tasks_files.any? { |f| File.exist?(f.to_s) } if config.deploy_tasks_files
499
+
500
+ path = default_deploy_tasks_path
501
+ path ? File.exist?(path.to_s) : false
502
+ end
503
+
504
+ # The engine initializer loads config/deploy_tasks.yml in a Rails boot;
505
+ # this covers a scheduler fork whose config was not loaded that way.
506
+ def load_deploy_tasks_config
507
+ return if config.deploy_tasks&.any?
508
+
509
+ files = config.deploy_tasks_files
510
+ tasks = if files
511
+ Recurring::ConfigLoader.load_all(files)
512
+ elsif (path = default_deploy_tasks_path) && File.exist?(path.to_s)
513
+ Recurring::ConfigLoader.load(path)
514
+ end
515
+ config.deploy_tasks = tasks if tasks&.any?
516
+ end
517
+
518
+ def default_deploy_tasks_path
519
+ Rails.root.join("config", "deploy_tasks.yml") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
520
+ end
521
+
491
522
  def boot_consumers
492
523
  return unless config.event_consumers
493
524
 
@@ -903,6 +934,7 @@ module Pgbus
903
934
  def start_heartbeat
904
935
  @heartbeat = Heartbeat.new(
905
936
  kind: "supervisor",
937
+ config: @config,
906
938
  metadata: { pid: ::Process.pid, hostname: Socket.gethostname, started_at: Time.now.to_f }
907
939
  )
908
940
  @heartbeat.start
@@ -836,6 +836,7 @@ module Pgbus
836
836
  def start_heartbeat
837
837
  @heartbeat = Heartbeat.new(
838
838
  kind: "worker",
839
+ config: @config,
839
840
  # capsule/process lead: the dashboard's metadata cell truncates.
840
841
  metadata: {
841
842
  capsule: @capsule, process: @process,
@@ -7,24 +7,33 @@ module Pgbus
7
7
  class Scheduler
8
8
  include Process::SignalHandler
9
9
 
10
- attr_reader :schedule, :config
10
+ attr_reader :schedule, :config, :deploy_runner
11
11
 
12
12
  def initialize(config: Pgbus.configuration)
13
13
  @config = config
14
14
  @schedule = Schedule.new(config: config)
15
+ # Deploy tasks (issue #536) ride this loop: the scheduler is already
16
+ # the one-per-deployment process, dedup'd across the overlap window.
17
+ @deploy_runner = Deploy::Runner.new(config: config) if config.deploy_tasks_enabled && config.deploy_tasks&.any?
15
18
  @shutting_down = false
16
19
  @last_runs = {}
17
20
  @loop_tick_at = Concurrent::AtomicReference.new(nil)
21
+ @deploy_error = nil
18
22
  end
19
23
 
24
+ # The order is load-bearing: the heartbeat registers this scheduler's row
25
+ # before it arms deploy tasks, so an old-revision scheduler in the
26
+ # overlap window sees it as lagging. A failed registration is logged and
27
+ # sync still runs; the new release's workers keep the old gate closed.
20
28
  def run
21
29
  setup_signals
22
30
  start_heartbeat
23
31
  sync_recurring_tasks
32
+ deploy_runner&.sync!
24
33
 
25
34
  Pgbus.logger.info do
26
35
  "[Pgbus] Scheduler started: #{schedule.tasks.size} recurring tasks, " \
27
- "interval=#{config.recurring_schedule_interval}s"
36
+ "#{deploy_task_count} deploy tasks, interval=#{config.recurring_schedule_interval}s"
28
37
  end
29
38
 
30
39
  loop do
@@ -44,22 +53,12 @@ module Pgbus
44
53
  end
45
54
 
46
55
  def tick(now)
47
- schedule.due_tasks(now).each do |task, run_at|
48
- Pgbus::Instrumentation.instrument(
49
- "pgbus.recurring.enqueue",
50
- task: task.key,
51
- class_name: task.class_name,
52
- queue: task.queue_name,
53
- run_at: run_at
54
- ) do
55
- schedule.enqueue_task(task, run_at: run_at)
56
- @last_runs[task.key] = now
57
- end
58
- rescue StandardError => e
59
- Pgbus.logger.error do
60
- "[Pgbus] Error scheduling recurring task #{task.key}: #{e.class}: #{e.message}"
61
- end
62
- end
56
+ tick_recurring(now) if config.recurring_enabled
57
+ tick_deploy_tasks(now)
58
+ end
59
+
60
+ def deploy_task_count
61
+ deploy_runner&.task_count || 0
63
62
  end
64
63
 
65
64
  def last_run_at(key)
@@ -94,9 +93,43 @@ module Pgbus
94
93
 
95
94
  private
96
95
 
96
+ def tick_recurring(now)
97
+ schedule.due_tasks(now).each do |task, run_at|
98
+ Pgbus::Instrumentation.instrument(
99
+ "pgbus.recurring.enqueue",
100
+ task: task.key,
101
+ class_name: task.class_name,
102
+ queue: task.queue_name,
103
+ run_at: run_at
104
+ ) do
105
+ schedule.enqueue_task(task, run_at: run_at)
106
+ @last_runs[task.key] = now
107
+ end
108
+ rescue StandardError => e
109
+ Pgbus.logger.error do
110
+ "[Pgbus] Error scheduling recurring task #{task.key}: #{e.class}: #{e.message}"
111
+ end
112
+ end
113
+ end
114
+
115
+ # A persistent failure (the tables missing, the database down) is logged
116
+ # and reported once per distinct error, then at debug, not every tick.
117
+ def tick_deploy_tasks(now)
118
+ deploy_runner&.tick(now)
119
+ @deploy_error = nil
120
+ rescue StandardError => e
121
+ signature = "#{e.class}: #{e.message}"
122
+ if signature == @deploy_error
123
+ Pgbus.logger.debug { "[Pgbus] Error running deploy tasks (repeated): #{signature}" }
124
+ else
125
+ @deploy_error = signature
126
+ ErrorReporter.report(e, { action: "deploy_tasks_tick" }) # logs at error itself
127
+ end
128
+ end
129
+
97
130
  def sync_recurring_tasks
98
131
  tasks = config.recurring_tasks
99
- return if tasks.nil?
132
+ return if tasks.nil? || !config.recurring_enabled
100
133
 
101
134
  RecurringTask.sync_from_config!(tasks)
102
135
  Pgbus.logger.info { "[Pgbus] Synced #{tasks.size} recurring task(s) from configuration" }
@@ -116,8 +149,9 @@ module Pgbus
116
149
  def start_heartbeat
117
150
  @heartbeat = Process::Heartbeat.new(
118
151
  kind: "scheduler",
152
+ config: @config,
119
153
  metadata: { pid: ::Process.pid, tasks: schedule.tasks.size, started_at: Time.now.to_f,
120
- supervisor_pid: ::Process.ppid },
154
+ supervisor_pid: ::Process.ppid }.merge(deploy_runner ? { deploy_tasks: deploy_task_count } : {}),
121
155
  loop_tick_supplier: -> { @loop_tick_at.get }
122
156
  )
123
157
  @heartbeat.start
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.19.0"
4
+ VERSION = "0.19.1"
5
5
  end