pgbus 0.17.1 → 0.17.3
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 +42 -0
- data/README.md +83 -13
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
- data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
- data/app/controllers/pgbus/application_controller.rb +5 -2
- data/app/controllers/pgbus/batches_controller.rb +4 -1
- data/app/controllers/pgbus/dashboard_controller.rb +20 -6
- data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
- data/app/controllers/pgbus/events_controller.rb +52 -3
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/controllers/pgbus/insights_controller.rb +19 -1
- data/app/controllers/pgbus/jobs_controller.rb +45 -26
- data/app/controllers/pgbus/locks_controller.rb +7 -2
- data/app/controllers/pgbus/outbox_controller.rb +4 -1
- data/app/controllers/pgbus/processes_controller.rb +3 -1
- data/app/controllers/pgbus/queues_controller.rb +16 -4
- data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +137 -77
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/helpers/pgbus/application_helper.rb +155 -113
- data/app/helpers/pgbus/button_helper.rb +88 -0
- data/app/helpers/pgbus/dashboard_helper.rb +157 -0
- data/app/helpers/pgbus/events_helper.rb +40 -0
- data/app/helpers/pgbus/insights_helper.rb +39 -0
- data/app/helpers/pgbus/processes_helper.rb +79 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
- data/app/views/pgbus/batches/show.html.erb +3 -4
- data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
- data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
- data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
- data/app/views/pgbus/dashboard/show.html.erb +1 -1
- data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
- data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
- data/app/views/pgbus/dead_letter/index.html.erb +6 -9
- data/app/views/pgbus/dead_letter/show.html.erb +37 -21
- data/app/views/pgbus/events/_list.html.erb +55 -0
- data/app/views/pgbus/events/_processed_table.html.erb +45 -0
- data/app/views/pgbus/events/_replay.html.erb +10 -0
- data/app/views/pgbus/events/_row.html.erb +162 -0
- data/app/views/pgbus/events/_tabs.html.erb +13 -0
- data/app/views/pgbus/events/index.html.erb +27 -48
- data/app/views/pgbus/events/show.html.erb +25 -7
- data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
- data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
- data/app/views/pgbus/insights/_status_table.html.erb +19 -0
- data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
- data/app/views/pgbus/insights/show.html.erb +83 -100
- data/app/views/pgbus/jobs/_list.html.erb +64 -0
- data/app/views/pgbus/jobs/_row.html.erb +107 -0
- data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
- data/app/views/pgbus/jobs/index.html.erb +10 -10
- data/app/views/pgbus/jobs/show.html.erb +18 -20
- data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
- data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
- data/app/views/pgbus/outbox/index.html.erb +30 -27
- data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
- data/app/views/pgbus/processes/_row.html.erb +48 -0
- data/app/views/pgbus/processes/_status.html.erb +4 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
- data/app/views/pgbus/queues/show.html.erb +69 -118
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
- data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
- data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
- data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
- data/app/views/pgbus/shared/_pager.html.erb +34 -0
- data/config/i18n-tasks.yml +11 -2
- data/config/locales/da.yml +405 -113
- data/config/locales/de.yml +405 -113
- data/config/locales/en.yml +405 -113
- data/config/locales/es.yml +404 -112
- data/config/locales/fi.yml +404 -112
- data/config/locales/fr.yml +406 -114
- data/config/locales/it.yml +405 -113
- data/config/locales/ja.yml +405 -113
- data/config/locales/nb.yml +405 -113
- data/config/locales/nl.yml +405 -113
- data/config/locales/pt.yml +406 -114
- data/config/locales/sv.yml +404 -112
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
- data/lib/pgbus/active_job/executor.rb +16 -6
- data/lib/pgbus/cli/dlq.rb +32 -5
- data/lib/pgbus/client.rb +4 -2
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration.rb +51 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/event_bus/handler.rb +18 -11
- data/lib/pgbus/event_bus/registry.rb +6 -0
- data/lib/pgbus/failed_event_recorder.rb +34 -0
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/instrumentation.rb +2 -1
- data/lib/pgbus/mcp/base_tool.rb +25 -0
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
- data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
- data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
- data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
- data/lib/pgbus/outbox/poller.rb +1 -1
- data/lib/pgbus/process/consumer.rb +53 -3
- data/lib/pgbus/process/dispatcher.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +32 -11
- data/lib/pgbus/process/worker.rb +45 -8
- data/lib/pgbus/recurring/scheduler.rb +2 -1
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
- data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
- data/lib/pgbus/web/data_source/event_list.rb +161 -0
- data/lib/pgbus/web/data_source/job_list.rb +323 -0
- data/lib/pgbus/web/data_source/list_counts.rb +80 -0
- data/lib/pgbus/web/data_source/process_list.rb +69 -0
- data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
- data/lib/pgbus/web/data_source.rb +132 -254
- data/lib/pgbus/web/dead_letter_reason.rb +81 -0
- data/lib/pgbus/web/event_state.rb +71 -0
- data/lib/pgbus/web/health_app.rb +7 -3
- data/lib/pgbus/web/health_verdict.rb +78 -0
- data/lib/pgbus/web/insights_charts.rb +84 -0
- data/lib/pgbus/web/job_state.rb +111 -0
- data/lib/pgbus/web/process_activity.rb +97 -0
- data/lib/pgbus/web/process_state.rb +187 -0
- data/lib/pgbus/web/queue_summary.rb +104 -0
- data/lib/pgbus/web/stream_app.rb +18 -6
- data/lib/pgbus/web/streamer/connection.rb +7 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +2 -1
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
- data/lib/pgbus/web/time_format.rb +128 -0
- metadata +55 -4
- data/app/views/pgbus/events/_pending_table.html.erb +0 -169
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
- data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
# Turns one queue's facts into the sentences an operator reads at the top
|
|
6
|
+
# of its page: why it is paused, who drains it, what is waiting, and which
|
|
7
|
+
# priority level it is (issue #491).
|
|
8
|
+
#
|
|
9
|
+
# Pure like JobState: it takes queue_detail, queue_pause_state and
|
|
10
|
+
# queue_drainers as given and does no I/O. Each Line carries a translation
|
|
11
|
+
# key under pgbus.queues.show.summary, raw args (Times under :ago/:time,
|
|
12
|
+
# seconds under :age) and a tone the view maps to colours.
|
|
13
|
+
module QueueSummary
|
|
14
|
+
Line = Data.define(:key, :args, :tone)
|
|
15
|
+
|
|
16
|
+
PRIORITY_LEVEL = /_p(\d+)\z/
|
|
17
|
+
FAILURES = /(\d+) consecutive failures/
|
|
18
|
+
BACKLOG_KEYS = %w[backlog backlog_parked_only empty].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def present(detail, pause_state, drainers, max_retries:)
|
|
23
|
+
name = detail[:name].to_s
|
|
24
|
+
dlq = name.end_with?(Pgbus::DEAD_LETTER_SUFFIX)
|
|
25
|
+
[
|
|
26
|
+
pause_line(pause_state),
|
|
27
|
+
drain_line(dlq, detail, drainers, max_retries),
|
|
28
|
+
(backlog_line(detail) unless dlq || drainers[:stream]),
|
|
29
|
+
priority_line(drainers)
|
|
30
|
+
].compact
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pause_line(state)
|
|
34
|
+
return unless state[:paused]
|
|
35
|
+
|
|
36
|
+
if state[:resumes_at]
|
|
37
|
+
failures = state[:reason].to_s[FAILURES, 1].to_i
|
|
38
|
+
return line("paused_circuit_breaker", :yellow, ago: state[:paused_at], failures: failures,
|
|
39
|
+
time: state[:resumes_at], trip: state[:trip_count].to_i)
|
|
40
|
+
end
|
|
41
|
+
return line("paused_operator_no_reason", :yellow, ago: state[:paused_at]) if state[:reason].to_s.strip.empty?
|
|
42
|
+
|
|
43
|
+
line("paused_operator", :yellow, ago: state[:paused_at], reason: state[:reason])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# First match wins, as in JobState#ready_overlay: what the queue is
|
|
47
|
+
# explains everything below it, then whether anything is configured to
|
|
48
|
+
# drain it, and only then whether a worker is alive.
|
|
49
|
+
def drain_line(dlq, detail, drainers, max_retries)
|
|
50
|
+
return line("dlq", :gray, logical: drainers[:logical], max: max_retries) if dlq
|
|
51
|
+
return line("stream", :gray) if drainers[:stream]
|
|
52
|
+
|
|
53
|
+
if drainers[:handler]
|
|
54
|
+
consumers = drainers[:live_consumers].to_i
|
|
55
|
+
return line("handler", :gray, count: consumers) if consumers.positive?
|
|
56
|
+
|
|
57
|
+
return line("no_consumers", :red, count: detail[:queue_visible_length].to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
capsules = Array(drainers[:capsules])
|
|
61
|
+
live = drainers[:live_workers].to_i
|
|
62
|
+
if capsules.empty? && !drainers[:wildcard]
|
|
63
|
+
# Capsules from `pgbus --queues` never reach the web process's
|
|
64
|
+
# configuration: a healthy heartbeat for this queue outranks it.
|
|
65
|
+
return live.zero? ? line("not_drained", :red) : line("drained_live", :gray, count: live)
|
|
66
|
+
end
|
|
67
|
+
return line("no_workers", :red, count: detail[:queue_visible_length].to_i) if live.zero?
|
|
68
|
+
return line("drained_wildcard", :gray, count: live) if capsules.empty?
|
|
69
|
+
|
|
70
|
+
line("drained_by", :gray, capsules: capsules.join(", "), count: live)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def backlog_line(detail)
|
|
74
|
+
visible = detail[:queue_visible_length].to_i
|
|
75
|
+
parked = detail[:parked_length].to_i
|
|
76
|
+
if visible.positive?
|
|
77
|
+
line("backlog", :gray, visible: visible, age: detail[:oldest_claimable_age_sec], parked: parked)
|
|
78
|
+
elsif parked.positive?
|
|
79
|
+
line("backlog_parked_only", :gray, parked: parked)
|
|
80
|
+
else
|
|
81
|
+
line("empty", :gray)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def priority_line(drainers)
|
|
86
|
+
level = drainers[:priority_level]
|
|
87
|
+
return unless level
|
|
88
|
+
|
|
89
|
+
line("priority_level", :gray, level: level, logical: drainers[:logical])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The one line a queue row has room for (the home page's queues table,
|
|
93
|
+
# issue #492): whatever needs attention (a pause, nothing draining it),
|
|
94
|
+
# else what is waiting, else what the queue is.
|
|
95
|
+
def headline(lines)
|
|
96
|
+
lines.find { |l| l.tone != :gray } || lines.find { |l| BACKLOG_KEYS.include?(l.key) } || lines.first
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def line(key, tone, **args)
|
|
100
|
+
Line.new(key: key, args: args, tone: tone)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -31,6 +31,9 @@ module Pgbus
|
|
|
31
31
|
# full request lifecycle.
|
|
32
32
|
class StreamApp
|
|
33
33
|
PATH_PREFIX = "/pgbus/streams"
|
|
34
|
+
# The client's per-tab presence seat (?seat=, issue #515): an opaque id
|
|
35
|
+
# minted by <pgbus-stream-source presence>. Anything else is dropped.
|
|
36
|
+
SEAT_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
34
37
|
# EventSource reconnect delay handed to the streams_test_mode stub (24h).
|
|
35
38
|
TEST_MODE_RETRY_MS = 86_400_000
|
|
36
39
|
private_constant :PATH_PREFIX
|
|
@@ -64,14 +67,16 @@ module Pgbus
|
|
|
64
67
|
cursor = parse_cursor(env, request)
|
|
65
68
|
return bad_request("invalid cursor: #{cursor}") if cursor.is_a?(String)
|
|
66
69
|
|
|
70
|
+
seat = parse_seat(request)
|
|
71
|
+
|
|
67
72
|
return test_mode_stub if config.streams_test_mode
|
|
68
73
|
|
|
69
74
|
return over_capacity if streamer.registry.size >= config.streams_max_connections
|
|
70
75
|
|
|
71
76
|
if config.streams_falcon_streaming_body
|
|
72
|
-
streaming_body_response(stream_name: stream_name, since_id: cursor, context: context)
|
|
77
|
+
streaming_body_response(stream_name: stream_name, since_id: cursor, context: context, seat: seat)
|
|
73
78
|
elsif env["rack.hijack?"]
|
|
74
|
-
hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context)
|
|
79
|
+
hijack_and_register(env, stream_name: stream_name, since_id: cursor, context: context, seat: seat)
|
|
75
80
|
else
|
|
76
81
|
unsupported_server
|
|
77
82
|
end
|
|
@@ -114,7 +119,12 @@ module Pgbus
|
|
|
114
119
|
e.message
|
|
115
120
|
end
|
|
116
121
|
|
|
117
|
-
def
|
|
122
|
+
def parse_seat(request)
|
|
123
|
+
seat = request.params["seat"]
|
|
124
|
+
seat if seat.is_a?(String) && seat.ascii_only? && SEAT_FORMAT.match?(seat)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def hijack_and_register(env, stream_name:, since_id:, context: nil, seat: nil)
|
|
118
128
|
# Rack hijack is a one-time transition per the Rack spec. Call
|
|
119
129
|
# once, use the return value, and fall back to the side-effect
|
|
120
130
|
# `rack.hijack_io` variable only if the return was nil (some
|
|
@@ -133,14 +143,15 @@ module Pgbus
|
|
|
133
143
|
since_id: since_id,
|
|
134
144
|
writer: Pgbus::Web::Streamer::IoWriter,
|
|
135
145
|
write_deadline_ms: config.streams_write_deadline_ms,
|
|
136
|
-
context: context
|
|
146
|
+
context: context,
|
|
147
|
+
seat: seat
|
|
137
148
|
)
|
|
138
149
|
streamer.register(connection)
|
|
139
150
|
|
|
140
151
|
[-1, {}, []]
|
|
141
152
|
end
|
|
142
153
|
|
|
143
|
-
def streaming_body_response(stream_name:, since_id:, context: nil)
|
|
154
|
+
def streaming_body_response(stream_name:, since_id:, context: nil, seat: nil)
|
|
144
155
|
require "protocol/http/body/writable" unless defined?(::Protocol::HTTP::Body::Writable)
|
|
145
156
|
require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
|
|
146
157
|
|
|
@@ -159,7 +170,8 @@ module Pgbus
|
|
|
159
170
|
body: body,
|
|
160
171
|
since_id: since_id,
|
|
161
172
|
write_deadline_ms: config.streams_write_deadline_ms,
|
|
162
|
-
context: context
|
|
173
|
+
context: context,
|
|
174
|
+
seat: seat
|
|
163
175
|
)
|
|
164
176
|
streamer.register(connection)
|
|
165
177
|
|
|
@@ -14,10 +14,15 @@ module Pgbus
|
|
|
14
14
|
# the client-side leg of the replay-race fix (§6.5 of the design doc).
|
|
15
15
|
class Connection
|
|
16
16
|
attr_reader :id, :stream_name, :io, :mutex, :context
|
|
17
|
+
# The client's per-tab presence seat (?seat=), or nil (issue #515).
|
|
18
|
+
attr_reader :seat
|
|
17
19
|
# The presence member id this connection auto-joined as, or nil for
|
|
18
20
|
# non-presence streams / anonymous connections. Set by the
|
|
19
21
|
# Dispatcher on connect; read on disconnect and heartbeat touch.
|
|
20
22
|
attr_accessor :presence_member
|
|
23
|
+
# True when presence_member joined as a seat row, false when as a
|
|
24
|
+
# member-keyed row (table not yet migrated). Leave follows the join.
|
|
25
|
+
attr_accessor :presence_seat
|
|
21
26
|
|
|
22
27
|
# last_msg_id_sent is the ONE field whose writer can cross threads: with
|
|
23
28
|
# streams_writer_threads > 0 (issue #321) the OutboundPump worker thread
|
|
@@ -31,7 +36,7 @@ module Pgbus
|
|
|
31
36
|
@last_msg_id_sent.get
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
|
|
39
|
+
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil, seat: nil)
|
|
35
40
|
@id = id
|
|
36
41
|
@stream_name = stream_name
|
|
37
42
|
@io = io
|
|
@@ -50,6 +55,7 @@ module Pgbus
|
|
|
50
55
|
# registry when evaluating visible_to predicates. Defaults to
|
|
51
56
|
# nil for tests that don't need audience filtering.
|
|
52
57
|
@context = context
|
|
58
|
+
@seat = seat
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
# deadline_ms defaults to the connection's own write deadline so every
|
|
@@ -13,7 +13,12 @@ module Pgbus
|
|
|
13
13
|
# is fiber-safe under Falcon's scheduler.
|
|
14
14
|
class FalconConnection
|
|
15
15
|
attr_reader :id, :stream_name, :io, :mutex, :context
|
|
16
|
+
# The client's per-tab presence seat (?seat=), or nil (issue #515).
|
|
17
|
+
attr_reader :seat
|
|
16
18
|
attr_accessor :presence_member
|
|
19
|
+
# True when presence_member joined as a seat row, false when as a
|
|
20
|
+
# member-keyed row (table not yet migrated). Leave follows the join.
|
|
21
|
+
attr_accessor :presence_seat
|
|
17
22
|
|
|
18
23
|
# last_msg_id_sent mirrors Connection's cross-thread cursor (issue #321):
|
|
19
24
|
# a Concurrent::AtomicReference so the OutboundPump worker's advance and
|
|
@@ -23,7 +28,7 @@ module Pgbus
|
|
|
23
28
|
@last_msg_id_sent.get
|
|
24
29
|
end
|
|
25
30
|
|
|
26
|
-
def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
|
|
31
|
+
def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil, seat: nil)
|
|
27
32
|
@id = id
|
|
28
33
|
@stream_name = stream_name
|
|
29
34
|
@body = body
|
|
@@ -37,6 +42,7 @@ module Pgbus
|
|
|
37
42
|
@created_at = monotonic
|
|
38
43
|
@last_write_at = @created_at
|
|
39
44
|
@context = context
|
|
45
|
+
@seat = seat
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# deadline_ms is accepted for duck-type parity with Connection#enqueue
|
|
@@ -26,13 +26,18 @@ module Pgbus
|
|
|
26
26
|
# the Connection's own mutex, so concurrent dispatcher + heartbeat
|
|
27
27
|
# writes are safe.
|
|
28
28
|
class Heartbeat
|
|
29
|
-
|
|
29
|
+
# presence_expiry: true (streams_presence_patterns is set) posts the
|
|
30
|
+
# presence touch every tick, even with no presence connection here,
|
|
31
|
+
# so the dispatcher can expire seats left by other processes.
|
|
32
|
+
def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil,
|
|
33
|
+
presence_expiry: false)
|
|
30
34
|
@registry = registry
|
|
31
35
|
@queue = dispatch_queue
|
|
32
36
|
@interval = interval
|
|
33
37
|
@idle_timeout = idle_timeout
|
|
34
38
|
@logger = logger
|
|
35
39
|
@clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
40
|
+
@presence_expiry = presence_expiry
|
|
36
41
|
@running = false
|
|
37
42
|
@thread = nil
|
|
38
43
|
@wake = ConditionVariable.new
|
|
@@ -122,9 +127,10 @@ module Pgbus
|
|
|
122
127
|
# Posts one batched touch per tick so the dispatcher (which owns AR
|
|
123
128
|
# connection release) refreshes last_seen_at for live presence
|
|
124
129
|
# members, keeping them out of the sweeper. No message when no
|
|
125
|
-
# connection has presence — avoids waking the
|
|
130
|
+
# connection has presence and seat expiry is off — avoids waking the
|
|
131
|
+
# dispatcher for nothing.
|
|
126
132
|
def enqueue_presence_touch(connections)
|
|
127
|
-
return if connections.empty?
|
|
133
|
+
return if connections.empty? && !@presence_expiry
|
|
128
134
|
|
|
129
135
|
@queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
|
|
130
136
|
end
|
|
@@ -74,7 +74,8 @@ module Pgbus
|
|
|
74
74
|
dispatch_queue: @dispatch_queue,
|
|
75
75
|
interval: @config.streams_heartbeat_interval,
|
|
76
76
|
idle_timeout: @config.streams_idle_timeout,
|
|
77
|
-
logger: @logger
|
|
77
|
+
logger: @logger,
|
|
78
|
+
presence_expiry: Array(@config.streams_presence_patterns).any?
|
|
78
79
|
)
|
|
79
80
|
@started = false
|
|
80
81
|
@shutdown_mutex = Mutex.new
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
module Streamer
|
|
6
|
+
# Connection-driven presence (issues #169, #515), run on the
|
|
7
|
+
# StreamEventDispatcher thread, which owns AR connection release.
|
|
8
|
+
#
|
|
9
|
+
# On a seat table (Pgbus::Streams::Presence.seats_supported?) every SSE
|
|
10
|
+
# connection is its own row, keyed by connection id and carrying the
|
|
11
|
+
# client's seat; two tabs of one member are two rows. On an older
|
|
12
|
+
# table it falls back to one member-keyed row, as before.
|
|
13
|
+
#
|
|
14
|
+
# The heartbeat posts a PresenceTouchMessage each tick; #touch refreshes
|
|
15
|
+
# the live seats, re-joins any whose row was expired while the
|
|
16
|
+
# connection stayed open, and expires silent seats across all streams
|
|
17
|
+
# (rows of crashed processes included) at most every min_ttl / 2 after
|
|
18
|
+
# a successful sweep.
|
|
19
|
+
#
|
|
20
|
+
# Each connection remembers whether it joined as a seat or as a
|
|
21
|
+
# member-keyed row, so a table migrated while it is open still has
|
|
22
|
+
# its row removed on leave (and converted to a seat on the next touch).
|
|
23
|
+
#
|
|
24
|
+
# Every failure is logged and swallowed: a presence DB hiccup must not
|
|
25
|
+
# knock a live SSE connection out of the registry.
|
|
26
|
+
class PresenceTracker
|
|
27
|
+
def initialize(config:, presence_provider:, logger:, sweeper: nil, clock: nil)
|
|
28
|
+
@config = config
|
|
29
|
+
@presence_provider = presence_provider
|
|
30
|
+
@logger = logger
|
|
31
|
+
@sweeper = sweeper || method(:expire_seats)
|
|
32
|
+
@clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
33
|
+
@last_sweep_at = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def join(connection, stream = connection.stream_name)
|
|
37
|
+
return unless @config&.stream_presence?(stream)
|
|
38
|
+
|
|
39
|
+
member = @config.presence_member_for(connection.context)
|
|
40
|
+
return unless member
|
|
41
|
+
|
|
42
|
+
presence = @presence_provider.call(stream)
|
|
43
|
+
seat = seats?(presence)
|
|
44
|
+
join_member(presence, connection, member, seat)
|
|
45
|
+
connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
|
|
46
|
+
connection.presence_seat = seat if connection.respond_to?(:presence_seat=)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
log_failure("join", e)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def leave(connection, stream = connection.stream_name)
|
|
52
|
+
member_id = presence_member_of(connection)
|
|
53
|
+
return unless member_id
|
|
54
|
+
|
|
55
|
+
# Cleared first: a heartbeat touch already queued behind this
|
|
56
|
+
# disconnect must not re-join the seat it removes.
|
|
57
|
+
connection.presence_member = nil if connection.respond_to?(:presence_member=)
|
|
58
|
+
presence = @presence_provider.call(stream)
|
|
59
|
+
if joined_as_seat?(connection, presence)
|
|
60
|
+
presence.leave(connection_id: connection.id)
|
|
61
|
+
else
|
|
62
|
+
presence.leave(member_id: member_id)
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
log_failure("leave", e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def touch(connections)
|
|
69
|
+
connections.select { |conn| presence_member_of(conn) }.group_by(&:stream_name).each do |stream, conns|
|
|
70
|
+
touch_stream(stream, conns)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
log_failure("touch", e)
|
|
73
|
+
end
|
|
74
|
+
sweep_if_due
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def join_member(presence, connection, member, seat)
|
|
80
|
+
metadata = member[:metadata] || {}
|
|
81
|
+
if seat
|
|
82
|
+
presence.join(member_id: member[:id], metadata: metadata,
|
|
83
|
+
connection_id: connection.id, seat: seat_of(connection))
|
|
84
|
+
else
|
|
85
|
+
presence.join(member_id: member[:id], metadata: metadata)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def touch_stream(stream, conns)
|
|
90
|
+
presence = @presence_provider.call(stream)
|
|
91
|
+
unless seats?(presence)
|
|
92
|
+
conns.each { |conn| presence.touch(member_id: presence_member_of(conn)) }
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
live = presence.touch(connection_id: conns.map(&:id))
|
|
97
|
+
conns.reject { |conn| live.include?(conn.id) }.each { |conn| rejoin_as_seat(presence, conn, stream) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A connection that joined as a member-keyed row before the table
|
|
101
|
+
# was migrated drops that row before it takes a seat.
|
|
102
|
+
def rejoin_as_seat(presence, connection, stream)
|
|
103
|
+
presence.leave(member_id: presence_member_of(connection)) unless joined_as_seat?(connection, presence)
|
|
104
|
+
join(connection, stream)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sweep_if_due
|
|
108
|
+
return if Array(@config.streams_presence_patterns).empty?
|
|
109
|
+
|
|
110
|
+
at = now
|
|
111
|
+
return if @last_sweep_at && at - @last_sweep_at < @config.presence_min_ttl / 2.0
|
|
112
|
+
|
|
113
|
+
@sweeper.call
|
|
114
|
+
@last_sweep_at = at
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
log_failure("expiry", e)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def expire_seats
|
|
120
|
+
Pgbus::Streams::Presence.expire_seats!(
|
|
121
|
+
ttl_for: ->(stream) { @config.presence_ttl_for(stream) }, min_ttl: @config.presence_min_ttl
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def seats?(presence)
|
|
126
|
+
presence.respond_to?(:seats?) && presence.seats?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The mode recorded at join; a connection without the accessor
|
|
130
|
+
# follows the table's current schema.
|
|
131
|
+
def joined_as_seat?(connection, presence)
|
|
132
|
+
return connection.presence_seat if connection.respond_to?(:presence_seat)
|
|
133
|
+
|
|
134
|
+
seats?(presence)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def seat_of(connection)
|
|
138
|
+
connection.respond_to?(:seat) ? connection.seat : nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def presence_member_of(connection)
|
|
142
|
+
connection.respond_to?(:presence_member) ? connection.presence_member : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def now
|
|
146
|
+
@clock.call
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def log_failure(action, error)
|
|
150
|
+
@logger.error { "[Pgbus::Streamer::PresenceTracker] presence #{action} failed: #{error.class}: #{error.message}" }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -105,6 +105,7 @@ module Pgbus
|
|
|
105
105
|
# process-wide setting. Falls back to the global config
|
|
106
106
|
# for production call sites that don't specify one.
|
|
107
107
|
@config = config || Pgbus.configuration
|
|
108
|
+
@presence = PresenceTracker.new(config: @config, presence_provider: @presence_provider, logger: @logger)
|
|
108
109
|
# Two write deadlines (issue #315 item 3). Fanout writes run serially
|
|
109
110
|
# on this single thread, so a slow client stalls the connections
|
|
110
111
|
# queued behind it — the short fanout deadline bounds that stall.
|
|
@@ -436,19 +437,11 @@ module Pgbus
|
|
|
436
437
|
)
|
|
437
438
|
end
|
|
438
439
|
|
|
439
|
-
# Touches
|
|
440
|
-
#
|
|
441
|
-
# still-connected members don't get swept
|
|
442
|
-
# presence member (non-presence streams, anonymous) are skipped.
|
|
440
|
+
# Touches the presence rows of the given connections and expires
|
|
441
|
+
# silent seats. Posted by the Heartbeat each tick so idle but
|
|
442
|
+
# still-connected members don't get swept (see PresenceTracker).
|
|
443
443
|
def handle_presence_touch(msg)
|
|
444
|
-
msg.connections
|
|
445
|
-
member_id = presence_member_of(connection)
|
|
446
|
-
next unless member_id
|
|
447
|
-
|
|
448
|
-
@presence_provider.call(connection.stream_name).touch(member_id: member_id)
|
|
449
|
-
rescue StandardError => e
|
|
450
|
-
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence touch failed: #{e.class}: #{e.message}" }
|
|
451
|
-
end
|
|
444
|
+
@presence.touch(msg.connections)
|
|
452
445
|
end
|
|
453
446
|
|
|
454
447
|
# If this stream has no remaining subscribers (registered or
|
|
@@ -640,35 +633,13 @@ module Pgbus
|
|
|
640
633
|
connection.respond_to?(:id) && connection.id.to_s == exclude.to_s
|
|
641
634
|
end
|
|
642
635
|
|
|
643
|
-
# Connection-driven presence (
|
|
644
|
-
# the stream is configured for presence and the connection's
|
|
645
|
-
# authorize-context yields a member id. Stores the member id on the
|
|
646
|
-
# connection so handle_disconnect/handle_presence_touch can act on
|
|
647
|
-
# it. Failures are logged and swallowed: a presence DB hiccup must
|
|
648
|
-
# not knock a live SSE connection out of the registry.
|
|
636
|
+
# Connection-driven presence (issues #169, #515): see PresenceTracker.
|
|
649
637
|
def presence_join(connection, stream)
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
member = @config.presence_member_for(connection.context)
|
|
653
|
-
return unless member
|
|
654
|
-
|
|
655
|
-
@presence_provider.call(stream).join(member_id: member[:id], metadata: member[:metadata] || {})
|
|
656
|
-
connection.presence_member = member[:id] if connection.respond_to?(:presence_member=)
|
|
657
|
-
rescue StandardError => e
|
|
658
|
-
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence join failed: #{e.class}: #{e.message}" }
|
|
638
|
+
@presence.join(connection, stream)
|
|
659
639
|
end
|
|
660
640
|
|
|
661
641
|
def presence_leave(connection, stream)
|
|
662
|
-
|
|
663
|
-
return unless member_id
|
|
664
|
-
|
|
665
|
-
@presence_provider.call(stream).leave(member_id: member_id)
|
|
666
|
-
rescue StandardError => e
|
|
667
|
-
@logger.error { "[Pgbus::Streamer::StreamEventDispatcher] presence leave failed: #{e.class}: #{e.message}" }
|
|
668
|
-
end
|
|
669
|
-
|
|
670
|
-
def presence_member_of(connection)
|
|
671
|
-
connection.respond_to?(:presence_member) ? connection.presence_member : nil
|
|
642
|
+
@presence.leave(connection, stream)
|
|
672
643
|
end
|
|
673
644
|
|
|
674
645
|
def monotonic_ms
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "active_support/core_ext/time"
|
|
5
|
+
require "active_support/core_ext/date_time"
|
|
6
|
+
|
|
7
|
+
module Pgbus
|
|
8
|
+
module Web
|
|
9
|
+
# Every timestamp, age and duration the dashboard prints (issue #497).
|
|
10
|
+
#
|
|
11
|
+
# Accepts the shapes the data sources hand the views: Time (pg decoders),
|
|
12
|
+
# ActiveSupport::TimeWithZone (AR attributes), DateTime, ISO 8601 or
|
|
13
|
+
# Time#to_s Strings (PGMQ JSON, payload["scheduled_at"]), Numeric epoch
|
|
14
|
+
# seconds (ActiveJob before 7.1), and nil. Every time is shown in
|
|
15
|
+
# Time.zone. Every word comes from pgbus.helpers.time in the locale.
|
|
16
|
+
# Pure: no I/O, no view context; the view helpers wrap it in <time>.
|
|
17
|
+
module TimeFormat
|
|
18
|
+
SCOPE = "pgbus.helpers.time"
|
|
19
|
+
NONE = "—"
|
|
20
|
+
MINUTE = 60
|
|
21
|
+
HOUR = 3600
|
|
22
|
+
DAY = 86_400
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def coerce(value)
|
|
27
|
+
case value
|
|
28
|
+
when nil then nil
|
|
29
|
+
when Time, DateTime, ActiveSupport::TimeWithZone then value.in_time_zone
|
|
30
|
+
when Numeric then epoch(value)
|
|
31
|
+
when String then parse(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# NaN and Infinity are not moments.
|
|
36
|
+
def epoch(seconds)
|
|
37
|
+
Time.at(seconds).in_time_zone
|
|
38
|
+
rescue RangeError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse(string)
|
|
43
|
+
string = string.strip
|
|
44
|
+
return nil if string.empty?
|
|
45
|
+
|
|
46
|
+
Time.zone ? Time.zone.parse(string) : Time.parse(string)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# "5m ago", "in 2h" or "now", measured from now:.
|
|
52
|
+
def relative(value, now: Time.current)
|
|
53
|
+
time = coerce(value)
|
|
54
|
+
return nil unless time
|
|
55
|
+
|
|
56
|
+
delta = time - now
|
|
57
|
+
return I18n.t("#{SCOPE}.now") if delta.abs < 1
|
|
58
|
+
|
|
59
|
+
distance = largest_unit(delta.abs.to_i)
|
|
60
|
+
I18n.t("#{SCOPE}.#{delta.negative? ? "past" : "future"}", distance: distance)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def absolute(value)
|
|
64
|
+
coerce(value)&.strftime(I18n.t("#{SCOPE}.absolute_format"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clock(value)
|
|
68
|
+
coerce(value)&.strftime(I18n.t("#{SCOPE}.clock_format"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def iso(value)
|
|
72
|
+
coerce(value)&.utc&.iso8601
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Two units at most: "45s", "2m 5s", "1h 2m", "1d 1h".
|
|
76
|
+
def duration(seconds)
|
|
77
|
+
return NONE unless seconds
|
|
78
|
+
|
|
79
|
+
# An age is never negative; one is clock skew between hosts.
|
|
80
|
+
seconds = [seconds.to_i, 0].max
|
|
81
|
+
if seconds < MINUTE then unit(:seconds, seconds)
|
|
82
|
+
elsif seconds < HOUR then pair(:minutes, seconds / MINUTE, :seconds, seconds % MINUTE)
|
|
83
|
+
elsif seconds < DAY then pair(:hours, seconds / HOUR, :minutes, (seconds % HOUR) / MINUTE)
|
|
84
|
+
else pair(:days, seconds / DAY, :hours, (seconds % DAY) / HOUR)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ms_duration(millis)
|
|
89
|
+
return NONE unless millis
|
|
90
|
+
|
|
91
|
+
millis = millis.to_i
|
|
92
|
+
if millis < 1000 then unit(:milliseconds, millis)
|
|
93
|
+
elsif millis < 60_000 then unit(:seconds, (millis / 1000.0).round(1))
|
|
94
|
+
else unit(:minutes, (millis / 60_000.0).round(1))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# "15 minutes", "6 hours", "7 days" for an Insights range in minutes.
|
|
99
|
+
def range_label(minutes)
|
|
100
|
+
minutes = [minutes.to_i, 1].max
|
|
101
|
+
|
|
102
|
+
if minutes > 1440 && (minutes % 1440).zero?
|
|
103
|
+
I18n.t("#{SCOPE}.range.days", count: minutes / 1440)
|
|
104
|
+
elsif minutes >= 60 && (minutes % 60).zero?
|
|
105
|
+
I18n.t("#{SCOPE}.range.hours", count: minutes / 60)
|
|
106
|
+
else
|
|
107
|
+
I18n.t("#{SCOPE}.range.minutes", count: minutes)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def largest_unit(seconds)
|
|
112
|
+
if seconds < MINUTE then unit(:seconds, seconds)
|
|
113
|
+
elsif seconds < HOUR then unit(:minutes, seconds / MINUTE)
|
|
114
|
+
elsif seconds < DAY then unit(:hours, seconds / HOUR)
|
|
115
|
+
else unit(:days, seconds / DAY)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pair(big, big_count, small, small_count)
|
|
120
|
+
"#{unit(big, big_count)} #{unit(small, small_count)}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def unit(name, count)
|
|
124
|
+
I18n.t("#{SCOPE}.units.#{name}", count: count)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|