pgbus 0.18.2 → 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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +345 -142
  3. data/README.md +49 -0
  4. data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
  5. data/app/controllers/pgbus/frontends_controller.rb +10 -8
  6. data/app/helpers/pgbus/application_helper.rb +4 -3
  7. data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
  8. data/app/helpers/pgbus/processes_helper.rb +1 -1
  9. data/app/models/pgbus/deploy_task.rb +104 -0
  10. data/app/models/pgbus/deploy_task_run.rb +45 -0
  11. data/app/models/pgbus/job_stat.rb +4 -0
  12. data/app/models/pgbus/processed_event.rb +3 -1
  13. data/app/models/pgbus/stream_queue.rb +34 -3
  14. data/app/models/pgbus/stream_stat.rb +3 -0
  15. data/app/views/layouts/pgbus/application.html.erb +2 -0
  16. data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
  17. data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
  18. data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
  19. data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
  20. data/config/i18n-tasks.yml +2 -0
  21. data/config/locales/da.yml +97 -0
  22. data/config/locales/de.yml +97 -0
  23. data/config/locales/en.yml +97 -0
  24. data/config/locales/es.yml +97 -0
  25. data/config/locales/fi.yml +97 -0
  26. data/config/locales/fr.yml +97 -0
  27. data/config/locales/it.yml +97 -0
  28. data/config/locales/ja.yml +97 -0
  29. data/config/locales/nb.yml +97 -0
  30. data/config/locales/nl.yml +97 -0
  31. data/config/locales/pt.yml +97 -0
  32. data/config/locales/sv.yml +97 -0
  33. data/config/routes.rb +7 -0
  34. data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
  35. data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
  36. data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
  37. data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
  38. data/lib/pgbus/active_job/current_attributes.rb +3 -1
  39. data/lib/pgbus/batch.rb +17 -9
  40. data/lib/pgbus/cli.rb +3 -0
  41. data/lib/pgbus/client/read_after.rb +10 -1
  42. data/lib/pgbus/client.rb +54 -9
  43. data/lib/pgbus/concurrency.rb +3 -0
  44. data/lib/pgbus/configuration/deploy_settings.rb +46 -0
  45. data/lib/pgbus/configuration.rb +87 -9
  46. data/lib/pgbus/current_attributes.rb +3 -1
  47. data/lib/pgbus/dedup_cache.rb +15 -7
  48. data/lib/pgbus/deploy/revision.rb +59 -0
  49. data/lib/pgbus/deploy/rollover.rb +65 -0
  50. data/lib/pgbus/deploy/runner.rb +259 -0
  51. data/lib/pgbus/deploy/task.rb +105 -0
  52. data/lib/pgbus/engine.rb +28 -0
  53. data/lib/pgbus/event_bus/handler.rb +16 -4
  54. data/lib/pgbus/execution_pools/async_pool.rb +23 -2
  55. data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
  56. data/lib/pgbus/execution_pools.rb +26 -0
  57. data/lib/pgbus/generators/migration_detector.rb +11 -0
  58. data/lib/pgbus/instrumentation.rb +3 -0
  59. data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
  60. data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
  61. data/lib/pgbus/integrations/appsignal.rb +26 -16
  62. data/lib/pgbus/mcp/rack_app.rb +1 -1
  63. data/lib/pgbus/mcp/server.rb +2 -1
  64. data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
  65. data/lib/pgbus/mcp.rb +1 -0
  66. data/lib/pgbus/metrics/subscriber.rb +35 -21
  67. data/lib/pgbus/outbox/poller.rb +1 -0
  68. data/lib/pgbus/process/consumer.rb +2 -1
  69. data/lib/pgbus/process/consumer_priority.rb +1 -1
  70. data/lib/pgbus/process/dispatcher.rb +18 -1
  71. data/lib/pgbus/process/heartbeat.rb +7 -2
  72. data/lib/pgbus/process/lifecycle.rb +5 -5
  73. data/lib/pgbus/process/notify_listener.rb +32 -18
  74. data/lib/pgbus/process/supervisor.rb +35 -3
  75. data/lib/pgbus/process/wake_pipe.rb +4 -1
  76. data/lib/pgbus/process/worker.rb +5 -2
  77. data/lib/pgbus/recurring/scheduler.rb +54 -20
  78. data/lib/pgbus/retry_backoff.rb +3 -0
  79. data/lib/pgbus/shareable.rb +70 -0
  80. data/lib/pgbus/streams/broadcastable_override.rb +3 -0
  81. data/lib/pgbus/streams/presence.rb +6 -7
  82. data/lib/pgbus/streams.rb +38 -27
  83. data/lib/pgbus/testing.rb +19 -3
  84. data/lib/pgbus/uniqueness.rb +3 -0
  85. data/lib/pgbus/version.rb +1 -1
  86. data/lib/pgbus/visibility_heartbeat.rb +51 -34
  87. data/lib/pgbus/web/authentication.rb +19 -2
  88. data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
  89. data/lib/pgbus/web/data_source/list_counts.rb +10 -7
  90. data/lib/pgbus/web/data_source.rb +1 -0
  91. data/lib/pgbus/web/deploy_task_state.rb +51 -0
  92. data/lib/pgbus/web/health_server.rb +4 -1
  93. data/lib/pgbus/web/process_activity.rb +1 -1
  94. data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
  95. data/lib/pgbus/web/streamer/hub_client.rb +4 -1
  96. data/lib/pgbus/web/streamer/listener.rb +10 -1
  97. data/lib/pgbus/web/streamer/master_hub.rb +11 -5
  98. data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
  99. data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
  100. data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
  101. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
  102. data/lib/pgbus.rb +92 -30
  103. data/sig/pgbus/active_job/adapter.rbs +57 -0
  104. data/sig/pgbus/client.rbs +78 -0
  105. data/sig/pgbus/configuration.rbs +326 -0
  106. data/sig/pgbus/streams/stream.rbs +25 -0
  107. data/sig/pgbus/streams.rbs +22 -0
  108. data/sig/pgbus.rbs +73 -0
  109. metadata +27 -1
@@ -37,7 +37,9 @@ module Pgbus
37
37
  notify_retry_backoff: NOTIFY_RETRY_BASE_SECONDS,
38
38
  started_at_monotonic: nil, wake_pipe: nil, read_ahead: nil,
39
39
  recycle_limits: {}, capsule: nil, process: nil)
40
- @queues = Array(queues)
40
+ # The worker's own list: eviction deletes from it, and the queues it is
41
+ # handed come from a configuration frozen by Pgbus.finalize! at boot.
42
+ @queues = Array(queues).dup
41
43
  @initial_queues = @queues.dup.freeze
42
44
  @wildcard = @queues.include?("*")
43
45
  @threads = threads
@@ -237,7 +239,7 @@ module Pgbus
237
239
  Pgbus.stopping = true
238
240
  @lifecycle.transition_to!(:stopped)
239
241
  @wake_signal.notify!
240
- @pool.kill
242
+ ExecutionPools.terminate(@pool, grace: config.immediate_shutdown_grace)
241
243
  end
242
244
 
243
245
  WILDCARD_REFRESH_INTERVAL = 30 # seconds
@@ -834,6 +836,7 @@ module Pgbus
834
836
  def start_heartbeat
835
837
  @heartbeat = Heartbeat.new(
836
838
  kind: "worker",
839
+ config: @config,
837
840
  # capsule/process lead: the dashboard's metadata cell truncates.
838
841
  metadata: {
839
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
@@ -20,6 +20,9 @@ module Pgbus
20
20
  module JobMixin
21
21
  extend ActiveSupport::Concern
22
22
 
23
+ # Class-body macro: it runs while the job class loads (at boot, or under the
24
+ # autoloader's lock), stores a frozen Hash, and the reader only reads it.
25
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
23
26
  class_methods do
24
27
  def pgbus_retry_backoff(base: nil, max: nil, jitter: nil)
25
28
  raise ArgumentError, "retry_backoff base must be > 0" if !base.nil? && (!base.is_a?(Numeric) || base <= 0)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Deep-frozen, Ractor-shareable copies of plain data, for the settings
5
+ # Configuration#finalize! freezes at boot (issue #527).
6
+ module Shareable
7
+ # Leaves that are plain data. Modules and classes count too: they are
8
+ # shareable by nature.
9
+ LEAVES = [String, Symbol, Numeric, TrueClass, FalseClass, NilClass, Regexp, Module].freeze
10
+
11
+ module_function
12
+
13
+ # True when +value+ is plain data all the way down: the LEAVES, and Arrays,
14
+ # Sets, Ranges and default-less Hashes of them. A Proc, a Logger or any
15
+ # other object anywhere inside makes it false, and so does a collection
16
+ # that contains itself (+path+ holds the collections being walked), so
17
+ # finalizing leaves it as it is instead of recursing without end.
18
+ def data?(value, path = {}.compare_by_identity)
19
+ case value
20
+ when Array, Set, Hash, Range
21
+ return false if path.key?(value)
22
+
23
+ path[value] = true
24
+ begin
25
+ collection_data?(value, path)
26
+ ensure
27
+ path.delete(value)
28
+ end
29
+ else LEAVES.any? { |leaf| value.is_a?(leaf) }
30
+ end
31
+ end
32
+
33
+ def collection_data?(value, path)
34
+ case value
35
+ when Array, Set then value.all? { |item| data?(item, path) }
36
+ when Hash
37
+ value.default_proc.nil? && data?(value.default, path) && value.all? { |k, v| data?(k, path) && data?(v, path) }
38
+ else data?(value.begin, path) && data?(value.end, path)
39
+ end
40
+ end
41
+
42
+ # A deep-frozen copy of plain data (see data?), Ractor-shareable where the
43
+ # engine has Ractors. The caller's own objects are copied, never frozen in
44
+ # place.
45
+ def freeze_copy(value)
46
+ return ::Ractor.make_shareable(value, copy: true) if defined?(::Ractor) && ::Ractor.respond_to?(:make_shareable)
47
+
48
+ deep_freeze(value)
49
+ end
50
+
51
+ # The copy freeze_copy makes where the engine has no Ractor: everything
52
+ # data? accepts, a Hash's default and a Range's endpoints included.
53
+ def deep_freeze(value)
54
+ case value
55
+ when Array then value.map { |item| deep_freeze(item) }.freeze
56
+ when Set then value.to_set { |item| deep_freeze(item) }.freeze
57
+ when Hash then deep_freeze_hash(value)
58
+ when Range then Range.new(deep_freeze(value.begin), deep_freeze(value.end), value.exclude_end?).freeze
59
+ when String then value.frozen? ? value : value.dup.freeze
60
+ else value
61
+ end
62
+ end
63
+
64
+ def deep_freeze_hash(hash)
65
+ copy = hash.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }
66
+ copy.default = deep_freeze(hash.default)
67
+ copy.freeze
68
+ end
69
+ end
70
+ end
@@ -56,6 +56,9 @@ module Pgbus
56
56
  end
57
57
  end
58
58
 
59
+ # broadcasts_to / broadcasts_refreshes_to are class-body macros: they write
60
+ # @pgbus_durable_streams while the model class loads, and it is only read after.
61
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
59
62
  module ClassMethods
60
63
  def broadcasts_to(stream, durable: nil, inserts_by: :append, target: broadcast_target_default, **rendering)
61
64
  if durable.nil?
@@ -77,11 +77,13 @@ module Pgbus
77
77
  # True when pgbus_presence_members has the seat columns (the
78
78
  # add_presence_seats migration ran). A true answer is kept for the
79
79
  # life of the process; a false one for SCHEMA_RECHECK_SECONDS.
80
+ #
81
+ # The answer is read and written under @schema_mutex only (issue
82
+ # #527). A successful probe is kept as above; a probe that raises is
83
+ # not, so the next call probes again.
80
84
  def seats_supported?
81
- return @seats_supported if schema_answer_fresh?
82
-
83
85
  @schema_mutex.synchronize do
84
- next @seats_supported if schema_answer_fresh?
86
+ next @seats_supported if schema_answer_fresh?(@seats_supported, @probed_at)
85
87
 
86
88
  rows = connection.exec_params(<<~SQL, [])
87
89
  SELECT 1 AS present FROM information_schema.columns
@@ -169,10 +171,7 @@ module Pgbus
169
171
 
170
172
  private
171
173
 
172
- # Reads each ivar once: a concurrent reset_schema_cache! may nil them.
173
- def schema_answer_fresh?
174
- supported = @seats_supported
175
- probed_at = @probed_at
174
+ def schema_answer_fresh?(supported, probed_at)
176
175
  return false if supported.nil? || probed_at.nil?
177
176
 
178
177
  supported || monotonic_now - probed_at < SCHEMA_RECHECK_SECONDS
data/lib/pgbus/streams.rb CHANGED
@@ -33,38 +33,49 @@ module Pgbus
33
33
  # `message`. Broadcasts may override it with a typed name (issue #170).
34
34
  DEFAULT_SSE_EVENT = "turbo-stream"
35
35
 
36
- # Process-wide registry of server-side audience filter predicates.
37
- # Register filters at boot time via:
38
- # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
39
- # See lib/pgbus/streams/filters.rb for the full API.
40
- def self.filters
41
- @filters ||= Filters.new
42
- end
36
+ class << self
37
+ # Process-wide registry of server-side audience filter predicates.
38
+ # Register filters at boot time via:
39
+ # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
40
+ # See lib/pgbus/streams/filters.rb for the full API.
41
+ #
42
+ # Process-wide publish-side coalescer for high-frequency broadcasts
43
+ # (issue #171); the flush re-enters the normal broadcast path so a
44
+ # coalesced frame is just a deferred ordinary broadcast.
45
+ #
46
+ # Both are built when this file loads, not by `||=` on first use: two
47
+ # first callers could each build a registry, and a filter registered on
48
+ # the one that lost was gone (issue #527). Replaced only by the resets.
49
+ attr_reader :filters, :coalescer
50
+
51
+ # Fresh filters registry. Used by tests; not intended for runtime.
52
+ def reset_filters!
53
+ @reset_lock.synchronize { @filters = Filters.new }
54
+ end
43
55
 
44
- # Clears the filters registry. Used by tests; not intended for runtime.
45
- def self.reset_filters!
46
- @filters = nil
47
- end
56
+ # Fresh coalescer. Used by tests; not intended for runtime.
57
+ def reset_coalescer!
58
+ @reset_lock.synchronize { @coalescer = build_coalescer }
59
+ end
48
60
 
49
- # Process-wide publish-side coalescer for high-frequency broadcasts
50
- # (issue #171). Lazily built; the flush re-enters the normal broadcast
51
- # path so a coalesced frame is just a deferred ordinary broadcast.
52
- def self.coalescer
53
- # `target:` is part of the flush signature but unused here — it's only
54
- # a coalescing key; the target is already encoded in the payload's
55
- # turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
56
- @coalescer ||= Coalescer.new(
57
- flush: lambda do |stream_name:, payload:, opts:, **|
58
- Pgbus.stream(stream_name).broadcast(payload, **opts)
59
- end
60
- )
61
- end
61
+ private
62
62
 
63
- # Clears the coalescer. Used by tests; not intended for runtime.
64
- def self.reset_coalescer!
65
- @coalescer = nil
63
+ def build_coalescer
64
+ # `target:` is part of the flush signature but unused here — it's only
65
+ # a coalescing key; the target is already encoded in the payload's
66
+ # turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
67
+ Coalescer.new(
68
+ flush: lambda do |stream_name:, payload:, opts:, **|
69
+ Pgbus.stream(stream_name).broadcast(payload, **opts)
70
+ end
71
+ )
72
+ end
66
73
  end
67
74
 
75
+ @reset_lock = Mutex.new
76
+ @filters = Filters.new
77
+ @coalescer = build_coalescer
78
+
68
79
  # A handle on a single logical stream. The name can be any string, an
69
80
  # object responding to `to_gid_param`, or an array of streamables (which
70
81
  # are joined with colons — turbo-rails-compatible).
data/lib/pgbus/testing.rb CHANGED
@@ -71,6 +71,15 @@ module Pgbus
71
71
  # Eagerly initialize the store so concurrent access never races on creation.
72
72
  @store = EventStore.new
73
73
 
74
+ # The process-wide mode (`fake!` with no block). A module ivar under a lock
75
+ # built at load, not `Thread.main[]`: that reads the main thread's CURRENT
76
+ # fiber only, so a mode set inside a fiber (an Async task) or read while the
77
+ # main thread runs one was lost (issue #527). The block form stays
78
+ # `Thread.current[MODE_KEY]`, which is fiber-local on every Ruby: a `fake!`
79
+ # block inside one Async task must not leak to its siblings.
80
+ @mode_lock = Mutex.new
81
+ @default_mode = nil
82
+
74
83
  class << self
75
84
  def mode!(mode, &block)
76
85
  raise ArgumentError, "Unknown mode: #{mode}. Valid modes: #{MODES.join(", ")}" unless MODES.include?(mode)
@@ -78,7 +87,7 @@ module Pgbus
78
87
  # Record the mode BEFORE syncing streams: a StreamerLeakError raised
79
88
  # by the teardown must still leave the process in the requested mode.
80
89
  unless block
81
- Thread.main[MODE_KEY] = mode
90
+ @mode_lock.synchronize { @default_mode = mode }
82
91
  sync_streams_test_mode!(mode)
83
92
  return
84
93
  end
@@ -90,12 +99,15 @@ module Pgbus
90
99
  ensure
91
100
  if block
92
101
  Thread.current[MODE_KEY] = old
93
- sync_streams_test_mode!(old || :disabled)
102
+ # The mode in effect again: the outer block's, else the
103
+ # process-wide one (not :disabled, which would let the streamer
104
+ # into a fake-mode test's later SSE requests).
105
+ sync_streams_test_mode!(old || default_mode)
94
106
  end
95
107
  end
96
108
 
97
109
  def mode
98
- Thread.current[MODE_KEY] || Thread.main[MODE_KEY] || :disabled
110
+ Thread.current[MODE_KEY] || default_mode
99
111
  end
100
112
 
101
113
  def fake!(&) = mode!(:fake, &)
@@ -110,6 +122,10 @@ module Pgbus
110
122
 
111
123
  private
112
124
 
125
+ def default_mode
126
+ @mode_lock.synchronize { @default_mode } || :disabled
127
+ end
128
+
113
129
  # When entering fake/inline mode, enable streams_test_mode to prevent
114
130
  # rack.hijack from spawning background threads that acquire DB
115
131
  # connections outside the test transaction (see issue #133). When
@@ -52,6 +52,9 @@ module Pgbus
52
52
  VALID_STRATEGIES = %i[until_executed while_executing].freeze
53
53
  VALID_CONFLICTS = %i[reject discard log].freeze
54
54
 
55
+ # Class-body macro: it runs while the job class loads (at boot, or under the
56
+ # autoloader's lock), stores a frozen Hash, and the reader only reads it.
57
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
55
58
  class_methods do
56
59
  def ensures_uniqueness(strategy: :until_executed, key: nil, on_conflict: :reject, **opts)
57
60
  # lock_ttl was validated and stored in message metadata but never read
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.18.2"
4
+ VERSION = "0.19.1"
5
5
  end
@@ -45,7 +45,9 @@ module Pgbus
45
45
  extend ActiveSupport::Concern
46
46
 
47
47
  included do
48
- class_attribute :pgbus_visibility_heartbeat_enabled, instance_writer: false, default: nil
48
+ # A per-class setting the job's own class body writes at load
49
+ # (`pgbus_visibility_heartbeat false`); jobs only read it while they run.
50
+ class_attribute :pgbus_visibility_heartbeat_enabled, instance_writer: false, default: nil # rubocop:disable ThreadSafety/ClassAndModuleAttributes
49
51
  end
50
52
 
51
53
  class_methods do
@@ -55,6 +57,14 @@ module Pgbus
55
57
  end
56
58
  end
57
59
 
60
+ # Built at load, never lazily: a `@mutex ||= Mutex.new` is itself a race in
61
+ # which two first callers each lock a Mutex of their own (issue #527). It
62
+ # guards @entries, @running, @thread and @pid, which are only touched inside
63
+ # #synchronize. A forked child can take it even when a parent thread held it
64
+ # at the fork: Ruby abandons a Mutex whose owner did not survive the fork.
65
+ @mutex = Mutex.new
66
+ @entries = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable -- only read or written inside #synchronize
67
+
58
68
  class << self
59
69
  # Track the message for the duration of the block.
60
70
  #
@@ -106,13 +116,13 @@ module Pgbus
106
116
  # can drive it.
107
117
  def tick!(now: monotonic_now, config: Pgbus.configuration)
108
118
  interval = config.effective_visibility_heartbeat_interval
109
- due = synchronize { entries.values.select { |entry| now - entry.extended_at >= interval } }
119
+ due = synchronize { @entries.values.select { |entry| now - entry.extended_at >= interval } }
110
120
  due.each { |entry| extend!(entry, now: now, config: config) }
111
121
  due.size
112
122
  end
113
123
 
114
124
  def tracked_count
115
- synchronize { entries.size }
125
+ synchronize { @entries.size }
116
126
  end
117
127
 
118
128
  # Stop the background thread. Tracked entries are kept: a job still
@@ -141,9 +151,20 @@ module Pgbus
141
151
 
142
152
  def register(entry, config)
143
153
  synchronize do
144
- forget_parent_entries!
145
- entries[key_for(entry)] = entry
146
- ensure_thread(config)
154
+ # Entries registered before a fork belong to the parent's jobs; the
155
+ # thread did not survive the fork either.
156
+ unless @pid == ::Process.pid
157
+ @pid = ::Process.pid
158
+ @entries = {}
159
+ @running = false
160
+ @thread = nil
161
+ end
162
+ @entries[key_for(entry)] = entry
163
+ # Start the ticker once per process.
164
+ unless @running && @thread&.alive?
165
+ @running = true
166
+ @thread = start_ticker(config)
167
+ end
147
168
  end
148
169
  end
149
170
 
@@ -153,7 +174,7 @@ module Pgbus
153
174
  def unregister(entry)
154
175
  synchronize do
155
176
  key = key_for(entry)
156
- entries.delete(key) if entries[key].equal?(entry)
177
+ @entries.delete(key) if @entries[key].equal?(entry)
157
178
  end
158
179
  end
159
180
 
@@ -186,31 +207,38 @@ module Pgbus
186
207
  end
187
208
  end
188
209
 
189
- # Caller holds the mutex. Start the ticker once per process.
190
- def ensure_thread(config)
191
- return if @running && @thread&.alive?
210
+ # One ticker per process, owned by this module: #register starts it under
211
+ # the lock (at most one live thread, see @running/@thread) and #stop
212
+ # clears @running, wakes it and joins it; Worker#shutdown calls #stop.
213
+ def start_ticker(config)
214
+ thread = Thread.new { run_loop(config) } # rubocop:disable ThreadSafety/NewThread
215
+ thread.name = "pgbus-visibility-heartbeat"
216
+ thread
217
+ end
192
218
 
193
- @running = true
194
- @thread = Thread.new { run_loop(config) }
195
- @thread.name = "pgbus-visibility-heartbeat"
219
+ # True while the calling thread is the ticker #register started last
220
+ # and #stop has not stopped it. A stopped ticker can wake after a
221
+ # #register has already started its successor; @running alone would be
222
+ # true again for it, and it would tick beside the new one.
223
+ def current_ticker?
224
+ synchronize { @running && @thread.equal?(Thread.current) }
196
225
  end
197
226
 
198
227
  def run_loop(config)
199
- while @running
228
+ while current_ticker?
200
229
  # Half the interval keeps every extension inside [interval, 1.5 * interval]
201
230
  # of the previous one — at most half the visibility timeout.
202
231
  sleep([config.effective_visibility_heartbeat_interval / 2.0, 0.05].max)
203
- break unless @running
232
+ break unless current_ticker?
204
233
 
205
234
  tick!(config: config)
206
235
  end
207
236
  rescue StandardError => e
208
237
  Pgbus.logger.error { "[Pgbus::VisibilityHeartbeat] ticker died: #{e.class}: #{e.message}" }
209
- synchronize { @running = false }
238
+ # Only its own state: a stopped ticker must not stop its successor.
239
+ synchronize { @running = false if @thread.equal?(Thread.current) }
210
240
  end
211
241
 
212
- # Entries registered before a fork belong to the parent's jobs; the
213
- # thread did not survive the fork either.
214
242
  # A live holder keeps its semaphore from being swept, so `duration`
215
243
  # bounds heartbeat silence rather than run time. Its own rescue: a
216
244
  # failed touch must not cost the message its visibility extension.
@@ -236,29 +264,18 @@ module Pgbus
236
264
  end
237
265
  end
238
266
 
239
- def forget_parent_entries!
240
- return if @pid == ::Process.pid
241
-
242
- @pid = ::Process.pid
243
- @entries = {}
244
- @running = false
245
- @thread = nil
246
- end
247
-
248
- def entries
249
- @entries ||= {}
250
- end
251
-
252
267
  def registered?(entry)
253
- synchronize { entries[key_for(entry)].equal?(entry) }
268
+ synchronize { @entries[key_for(entry)].equal?(entry) }
254
269
  end
255
270
 
256
271
  def key_for(entry)
257
272
  [entry.queue_name, entry.msg_id]
258
273
  end
259
274
 
260
- def synchronize(&)
261
- (@mutex ||= Mutex.new).synchronize(&)
275
+ # A literal block, not `(&)`: ThreadSafety/ClassInstanceVariable accepts
276
+ # @mutex as the receiver of a synchronize block only.
277
+ def synchronize
278
+ @mutex.synchronize { yield } # rubocop:disable Style/ExplicitBlockArgument
262
279
  end
263
280
 
264
281
  def monotonic_now
@@ -9,8 +9,25 @@ module Pgbus
9
9
  before_action :authenticate_pgbus!
10
10
  end
11
11
 
12
+ # The "dashboard has no authentication" warning is logged once per
13
+ # process. Request threads check and set the flag under @warn_lock, so
14
+ # concurrent first requests cannot both log it (issue #527).
15
+ @warn_lock = Mutex.new
16
+ @auth_warned = false
17
+
12
18
  class << self
13
- attr_accessor :auth_warned
19
+ def auth_warned
20
+ @warn_lock.synchronize { @auth_warned }
21
+ end
22
+
23
+ def auth_warned=(value)
24
+ @warn_lock.synchronize { @auth_warned = value }
25
+ end
26
+
27
+ # True for the one caller that flips the flag from false to true.
28
+ def claim_auth_warning!
29
+ @warn_lock.synchronize { @auth_warned ? false : (@auth_warned = true) }
30
+ end
14
31
  end
15
32
 
16
33
  # Default base controller — anything else means the app deliberately
@@ -40,13 +57,13 @@ module Pgbus
40
57
  # place; don't nag apps that gate the dashboard via a custom controller
41
58
  # (they had to set a redundant web_auth lambda just to silence us — #334).
42
59
  return unless default_base_controller?
60
+ return unless Pgbus::Web::Authentication.claim_auth_warning!
43
61
 
44
62
  Pgbus.logger.warn do
45
63
  "[Pgbus] Dashboard is accessible without authentication. " \
46
64
  "Configure Pgbus.configuration.web_auth to restrict access. " \
47
65
  "See: https://github.com/zoolutions/pgbus#dashboard-authentication"
48
66
  end
49
- Pgbus::Web::Authentication.auth_warned = true
50
67
  end
51
68
 
52
69
  # True when base_controller_class is (still) the default ActionController::Base.