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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +345 -142
- data/README.md +49 -0
- data/app/controllers/pgbus/deploy_tasks_controller.rb +38 -0
- data/app/controllers/pgbus/frontends_controller.rb +10 -8
- data/app/helpers/pgbus/application_helper.rb +4 -3
- data/app/helpers/pgbus/deploy_tasks_helper.rb +77 -0
- data/app/helpers/pgbus/processes_helper.rb +1 -1
- data/app/models/pgbus/deploy_task.rb +104 -0
- data/app/models/pgbus/deploy_task_run.rb +45 -0
- data/app/models/pgbus/job_stat.rb +4 -0
- data/app/models/pgbus/processed_event.rb +3 -1
- data/app/models/pgbus/stream_queue.rb +34 -3
- data/app/models/pgbus/stream_stat.rb +3 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/deploy_tasks/_rollover_banner.html.erb +25 -0
- data/app/views/pgbus/deploy_tasks/_tasks_table.html.erb +71 -0
- data/app/views/pgbus/deploy_tasks/index.html.erb +6 -0
- data/app/views/pgbus/deploy_tasks/show.html.erb +134 -0
- data/config/i18n-tasks.yml +2 -0
- data/config/locales/da.yml +97 -0
- data/config/locales/de.yml +97 -0
- data/config/locales/en.yml +97 -0
- data/config/locales/es.yml +97 -0
- data/config/locales/fi.yml +97 -0
- data/config/locales/fr.yml +97 -0
- data/config/locales/it.yml +97 -0
- data/config/locales/ja.yml +97 -0
- data/config/locales/nb.yml +97 -0
- data/config/locales/nl.yml +97 -0
- data/config/locales/pt.yml +97 -0
- data/config/locales/sv.yml +97 -0
- data/config/routes.rb +7 -0
- data/lib/generators/pgbus/add_deploy_tasks_generator.rb +51 -0
- data/lib/generators/pgbus/templates/add_deploy_tasks_tables.rb.erb +43 -0
- data/lib/generators/pgbus/templates/deploy_tasks.yml.erb +42 -0
- data/lib/generators/pgbus/templates/migration.rb.erb +43 -0
- data/lib/pgbus/active_job/current_attributes.rb +3 -1
- data/lib/pgbus/batch.rb +17 -9
- data/lib/pgbus/cli.rb +3 -0
- data/lib/pgbus/client/read_after.rb +10 -1
- data/lib/pgbus/client.rb +54 -9
- data/lib/pgbus/concurrency.rb +3 -0
- data/lib/pgbus/configuration/deploy_settings.rb +46 -0
- data/lib/pgbus/configuration.rb +87 -9
- data/lib/pgbus/current_attributes.rb +3 -1
- data/lib/pgbus/dedup_cache.rb +15 -7
- data/lib/pgbus/deploy/revision.rb +59 -0
- data/lib/pgbus/deploy/rollover.rb +65 -0
- data/lib/pgbus/deploy/runner.rb +259 -0
- data/lib/pgbus/deploy/task.rb +105 -0
- data/lib/pgbus/engine.rb +28 -0
- data/lib/pgbus/event_bus/handler.rb +16 -4
- data/lib/pgbus/execution_pools/async_pool.rb +23 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +22 -0
- data/lib/pgbus/execution_pools.rb +26 -0
- data/lib/pgbus/generators/migration_detector.rb +11 -0
- data/lib/pgbus/instrumentation.rb +3 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +17 -9
- data/lib/pgbus/integrations/appsignal/subscriber.rb +30 -18
- data/lib/pgbus/integrations/appsignal.rb +26 -16
- data/lib/pgbus/mcp/rack_app.rb +1 -1
- data/lib/pgbus/mcp/server.rb +2 -1
- data/lib/pgbus/mcp/tools/deploy_tasks_tool.rb +86 -0
- data/lib/pgbus/mcp.rb +1 -0
- data/lib/pgbus/metrics/subscriber.rb +35 -21
- data/lib/pgbus/outbox/poller.rb +1 -0
- data/lib/pgbus/process/consumer.rb +2 -1
- data/lib/pgbus/process/consumer_priority.rb +1 -1
- data/lib/pgbus/process/dispatcher.rb +18 -1
- data/lib/pgbus/process/heartbeat.rb +7 -2
- data/lib/pgbus/process/lifecycle.rb +5 -5
- data/lib/pgbus/process/notify_listener.rb +32 -18
- data/lib/pgbus/process/supervisor.rb +35 -3
- data/lib/pgbus/process/wake_pipe.rb +4 -1
- data/lib/pgbus/process/worker.rb +5 -2
- data/lib/pgbus/recurring/scheduler.rb +54 -20
- data/lib/pgbus/retry_backoff.rb +3 -0
- data/lib/pgbus/shareable.rb +70 -0
- data/lib/pgbus/streams/broadcastable_override.rb +3 -0
- data/lib/pgbus/streams/presence.rb +6 -7
- data/lib/pgbus/streams.rb +38 -27
- data/lib/pgbus/testing.rb +19 -3
- data/lib/pgbus/uniqueness.rb +3 -0
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +51 -34
- data/lib/pgbus/web/authentication.rb +19 -2
- data/lib/pgbus/web/data_source/deploy_task_list.rb +114 -0
- data/lib/pgbus/web/data_source/list_counts.rb +10 -7
- data/lib/pgbus/web/data_source.rb +1 -0
- data/lib/pgbus/web/deploy_task_state.rb +51 -0
- data/lib/pgbus/web/health_server.rb +4 -1
- data/lib/pgbus/web/process_activity.rb +1 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +4 -1
- data/lib/pgbus/web/streamer/hub_client.rb +4 -1
- data/lib/pgbus/web/streamer/listener.rb +10 -1
- data/lib/pgbus/web/streamer/master_hub.rb +11 -5
- data/lib/pgbus/web/streamer/master_hub_boot.rb +3 -1
- data/lib/pgbus/web/streamer/outbound_pump.rb +4 -1
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +4 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +13 -1
- data/lib/pgbus.rb +92 -30
- data/sig/pgbus/active_job/adapter.rbs +57 -0
- data/sig/pgbus/client.rbs +78 -0
- data/sig/pgbus/configuration.rbs +326 -0
- data/sig/pgbus/streams/stream.rbs +25 -0
- data/sig/pgbus/streams.rbs +22 -0
- data/sig/pgbus.rbs +73 -0
- metadata +27 -1
data/lib/pgbus/process/worker.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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/retry_backoff.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ||
|
|
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
|
data/lib/pgbus/uniqueness.rb
CHANGED
|
@@ -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
|
@@ -45,7 +45,9 @@ module Pgbus
|
|
|
45
45
|
extend ActiveSupport::Concern
|
|
46
46
|
|
|
47
47
|
included do
|
|
48
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
#
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|