pgbus 0.8.4 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +150 -5
- data/app/controllers/pgbus/batches_controller.rb +15 -0
- data/app/helpers/pgbus/application_helper.rb +12 -0
- data/app/views/layouts/pgbus/application.html.erb +2 -0
- data/app/views/pgbus/batches/_batches_table.html.erb +54 -0
- data/app/views/pgbus/batches/index.html.erb +8 -0
- data/app/views/pgbus/batches/show.html.erb +90 -0
- data/config/locales/da.yml +34 -0
- data/config/locales/de.yml +34 -0
- data/config/locales/en.yml +34 -0
- data/config/locales/es.yml +34 -0
- data/config/locales/fi.yml +34 -0
- data/config/locales/fr.yml +34 -0
- data/config/locales/it.yml +34 -0
- data/config/locales/ja.yml +34 -0
- data/config/locales/nb.yml +34 -0
- data/config/locales/nl.yml +34 -0
- data/config/locales/pt.yml +34 -0
- data/config/locales/sv.yml +34 -0
- data/config/routes.rb +1 -0
- data/lib/pgbus/client.rb +102 -4
- data/lib/pgbus/configuration.rb +84 -0
- data/lib/pgbus/execution_pools/async_pool.rb +44 -6
- data/lib/pgbus/process/supervisor.rb +2 -1
- data/lib/pgbus/process/worker.rb +38 -1
- data/lib/pgbus/serializer.rb +1 -1
- data/lib/pgbus/streams/coalescer.rb +88 -0
- data/lib/pgbus/streams/envelope.rb +20 -1
- data/lib/pgbus/streams/key.rb +35 -2
- data/lib/pgbus/streams/renderer.rb +67 -0
- data/lib/pgbus/streams.rb +150 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +96 -0
- data/lib/pgbus/web/stream_app.rb +8 -4
- data/lib/pgbus/web/streamer/connection.rb +15 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +9 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +23 -1
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +129 -14
- data/lib/pgbus.rb +11 -0
- data/lib/tasks/pgbus_queues.rake +54 -0
- metadata +10 -3
data/lib/pgbus/streams.rb
CHANGED
|
@@ -15,6 +15,12 @@ module Pgbus
|
|
|
15
15
|
# this specifically can rescue Pgbus::Streams::StreamNameTooLong.
|
|
16
16
|
class StreamNameTooLong < ArgumentError; end
|
|
17
17
|
|
|
18
|
+
# The default SSE `event:` name for a broadcast frame. Turbo's
|
|
19
|
+
# StreamObserver consumes frames the client re-dispatches as the
|
|
20
|
+
# `message` DOM event; the client maps this SSE event name to
|
|
21
|
+
# `message`. Broadcasts may override it with a typed name (issue #170).
|
|
22
|
+
DEFAULT_SSE_EVENT = "turbo-stream"
|
|
23
|
+
|
|
18
24
|
# Process-wide registry of server-side audience filter predicates.
|
|
19
25
|
# Register filters at boot time via:
|
|
20
26
|
# Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
|
|
@@ -28,6 +34,25 @@ module Pgbus
|
|
|
28
34
|
@filters = nil
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# Process-wide publish-side coalescer for high-frequency broadcasts
|
|
38
|
+
# (issue #171). Lazily built; the flush re-enters the normal broadcast
|
|
39
|
+
# path so a coalesced frame is just a deferred ordinary broadcast.
|
|
40
|
+
def self.coalescer
|
|
41
|
+
# `target:` is part of the flush signature but unused here — it's only
|
|
42
|
+
# a coalescing key; the target is already encoded in the payload's
|
|
43
|
+
# turbo-stream tag. Absorbed via ** so it isn't a broadcast argument.
|
|
44
|
+
@coalescer ||= Coalescer.new(
|
|
45
|
+
flush: lambda do |stream_name:, payload:, opts:, **|
|
|
46
|
+
Pgbus.stream(stream_name).broadcast(payload, **opts)
|
|
47
|
+
end
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clears the coalescer. Used by tests; not intended for runtime.
|
|
52
|
+
def self.reset_coalescer!
|
|
53
|
+
@coalescer = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
# A handle on a single logical stream. The name can be any string, an
|
|
32
57
|
# object responding to `to_gid_param`, or an array of streamables (which
|
|
33
58
|
# are joined with colons — turbo-rails-compatible).
|
|
@@ -76,9 +101,48 @@ module Pgbus
|
|
|
76
101
|
# Per-broadcast `durable:` overrides the stream-level default for a
|
|
77
102
|
# single broadcast. `nil` (the default) defers to the stream's own
|
|
78
103
|
# `durable?` setting; `true`/`false` flip the mode for this call only.
|
|
79
|
-
|
|
104
|
+
#
|
|
105
|
+
# Actor-echo suppression: pass `exclude:` with the broadcaster's own
|
|
106
|
+
# SSE connection id (surfaced to the page as
|
|
107
|
+
# `<meta name="pgbus-connection-id">` / the element's `connection-id`
|
|
108
|
+
# attribute, sent back on the action request as the
|
|
109
|
+
# `X-Pgbus-Connection` header). The dispatcher skips delivery to that
|
|
110
|
+
# one connection, so the actor doesn't receive the echo of its own
|
|
111
|
+
# broadcast — it already applied the change via the action's HTTP
|
|
112
|
+
# response. Everyone else gets the broadcast. A nil/blank `exclude`
|
|
113
|
+
# is a no-op (the common path).
|
|
114
|
+
# Typed SSE event name: pass `event:` to set the SSE `event:` field
|
|
115
|
+
# on the delivered frame (e.g. `event: "presence"`, `event:
|
|
116
|
+
# "reactive"`) while keeping the payload a Turbo Stream. Clients that
|
|
117
|
+
# care can route on the typed event without sniffing the HTML;
|
|
118
|
+
# default consumers still receive the standard turbo-stream/`message`
|
|
119
|
+
# path. The default (`nil` or `"turbo-stream"`) is not carried on the
|
|
120
|
+
# wire — it's the implicit default the dispatcher applies.
|
|
121
|
+
# High-frequency coalescing: pass `coalesce:` (a window in milliseconds,
|
|
122
|
+
# or `true` for the default window) together with `target:` to batch
|
|
123
|
+
# rapid broadcasts to the same (stream, target) and publish only the
|
|
124
|
+
# latest within the window. Superseded frames never reach the bus.
|
|
125
|
+
# Last-write-wins, so this is only safe for idempotent replace/update
|
|
126
|
+
# of a stable target (the high-frequency case: cursors, typing,
|
|
127
|
+
# progress). Returns nil — the actual broadcast is deferred to the
|
|
128
|
+
# coalescer's flush. See issue #171 and Pgbus::Streams::Coalescer.
|
|
129
|
+
def broadcast(payload, visible_to: nil, durable: nil, exclude: nil, event: nil, coalesce: nil, target: nil)
|
|
130
|
+
if coalesce
|
|
131
|
+
return coalesce_broadcast(
|
|
132
|
+
payload,
|
|
133
|
+
coalesce: coalesce,
|
|
134
|
+
target: target,
|
|
135
|
+
visible_to: visible_to,
|
|
136
|
+
durable: durable,
|
|
137
|
+
exclude: exclude,
|
|
138
|
+
event: event
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
80
142
|
wrapped = { "html" => payload.to_s }
|
|
81
143
|
wrapped["visible_to"] = visible_to.to_s if visible_to
|
|
144
|
+
wrapped["exclude"] = exclude.to_s if exclude && !exclude.to_s.empty?
|
|
145
|
+
wrapped["event"] = event.to_s if event && event.to_s != DEFAULT_SSE_EVENT
|
|
82
146
|
|
|
83
147
|
use_durable = durable.nil? ? @durable : durable
|
|
84
148
|
return broadcast_ephemeral(wrapped) unless use_durable
|
|
@@ -101,6 +165,36 @@ module Pgbus
|
|
|
101
165
|
end
|
|
102
166
|
end
|
|
103
167
|
|
|
168
|
+
# Renders a renderable (a Phlex component, a ViewComponent, or a
|
|
169
|
+
# pre-rendered HTML string) into a complete `<turbo-stream>` action
|
|
170
|
+
# tag and broadcasts it atomically — the render and the broadcast in
|
|
171
|
+
# one call. This removes the #1 footgun in server-driven UI: building
|
|
172
|
+
# the off-request render context and the turbo-stream wrapper by hand
|
|
173
|
+
# at every call site.
|
|
174
|
+
#
|
|
175
|
+
# Pgbus.stream("chat", room).broadcast_render(
|
|
176
|
+
# renderable: Chat::Message.new(chat_message: msg),
|
|
177
|
+
# action: :append, target: "chat-messages-#{room}",
|
|
178
|
+
# exclude: connection_id # composes with #165
|
|
179
|
+
# )
|
|
180
|
+
#
|
|
181
|
+
# `action` defaults to :replace. `target` is required. `exclude:`,
|
|
182
|
+
# `visible_to:`, and `durable:` are forwarded to #broadcast unchanged,
|
|
183
|
+
# so actor-echo suppression and audience filtering compose. Returns
|
|
184
|
+
# whatever #broadcast returns (msg_id, or nil when deferred to
|
|
185
|
+
# after_commit inside a transaction).
|
|
186
|
+
#
|
|
187
|
+
# See Pgbus::Streams::Renderer for the renderable-resolution order.
|
|
188
|
+
# Components that need URL helpers or a full view context should be
|
|
189
|
+
# rendered by the app (which has the request context) and the
|
|
190
|
+
# resulting string passed as `renderable:`.
|
|
191
|
+
def broadcast_render(target:, action: :replace, renderable: nil, visible_to: nil, durable: nil, exclude: nil,
|
|
192
|
+
event: nil, coalesce: nil)
|
|
193
|
+
html = Renderer.turbo_stream_tag(action: action, target: target, renderable: renderable)
|
|
194
|
+
broadcast(html, visible_to: visible_to, durable: durable, exclude: exclude, event: event,
|
|
195
|
+
coalesce: coalesce, target: target)
|
|
196
|
+
end
|
|
197
|
+
|
|
104
198
|
def current_msg_id
|
|
105
199
|
@client.stream_current_msg_id(@name)
|
|
106
200
|
end
|
|
@@ -172,6 +266,61 @@ module Pgbus
|
|
|
172
266
|
nil
|
|
173
267
|
end
|
|
174
268
|
|
|
269
|
+
# Submits a frame to the process-wide coalescer instead of
|
|
270
|
+
# broadcasting now. Requires a target (the dedupe key — there's no
|
|
271
|
+
# way to last-write-win without one). The window is `coalesce` in ms
|
|
272
|
+
# when numeric, else the coalescer's default. Returns nil; the
|
|
273
|
+
# coalescer flushes the latest frame as an ordinary broadcast.
|
|
274
|
+
def coalesce_broadcast(payload, coalesce:, target:, visible_to:, durable:, exclude:, event:)
|
|
275
|
+
if target.nil? || target.to_s.empty?
|
|
276
|
+
raise ArgumentError,
|
|
277
|
+
"broadcast(coalesce:) requires target: — coalescing keys on (stream, target), " \
|
|
278
|
+
"so the latest frame per target can win. Use broadcast_render(coalesce:) which " \
|
|
279
|
+
"already has a target, or pass target: explicitly."
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
window_ms = coalesce_window_ms(coalesce)
|
|
283
|
+
# Resolve durability against THIS stream instance now, not nil. The
|
|
284
|
+
# coalescer's flush re-enters Pgbus.stream(@name), whose durability
|
|
285
|
+
# could resolve differently (config patterns) than this instance —
|
|
286
|
+
# passing the resolved value keeps the coalesced frame's mode stable.
|
|
287
|
+
resolved_durable = durable.nil? ? @durable : durable
|
|
288
|
+
|
|
289
|
+
submit = lambda do
|
|
290
|
+
Streams.coalescer.submit(
|
|
291
|
+
stream_name: @name,
|
|
292
|
+
target: target.to_s,
|
|
293
|
+
payload: payload.to_s,
|
|
294
|
+
window_ms: window_ms,
|
|
295
|
+
opts: { visible_to: visible_to, durable: resolved_durable, exclude: exclude, event: event }
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Transaction gating: a coalesced DURABLE broadcast made inside an
|
|
300
|
+
# open transaction must not flush if the transaction rolls back, so
|
|
301
|
+
# defer the submission to after_commit (mirrors the non-coalesced
|
|
302
|
+
# path). Ephemeral coalescing is fire-and-forget, so it submits now.
|
|
303
|
+
transaction = resolved_durable ? current_open_transaction : nil
|
|
304
|
+
if transaction
|
|
305
|
+
transaction.after_commit { submit.call }
|
|
306
|
+
else
|
|
307
|
+
submit.call
|
|
308
|
+
end
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Resolves the coalescing window in ms. `true` → the default window;
|
|
313
|
+
# a positive Numeric is used as-is. Anything else is a misuse and is
|
|
314
|
+
# rejected at the API boundary with an actionable error rather than a
|
|
315
|
+
# cryptic NoMethodError deep inside the coalescer's timer math.
|
|
316
|
+
def coalesce_window_ms(coalesce)
|
|
317
|
+
return Coalescer::DEFAULT_WINDOW_MS if coalesce == true
|
|
318
|
+
return coalesce if coalesce.is_a?(Numeric) && coalesce.positive?
|
|
319
|
+
|
|
320
|
+
raise ArgumentError,
|
|
321
|
+
"coalesce: must be true or a positive Numeric window in milliseconds, got #{coalesce.inspect}"
|
|
322
|
+
end
|
|
323
|
+
|
|
175
324
|
def ensure_queue!
|
|
176
325
|
return if @ensured
|
|
177
326
|
|
data/lib/pgbus/version.rb
CHANGED
|
@@ -590,6 +590,43 @@ module Pgbus
|
|
|
590
590
|
[]
|
|
591
591
|
end
|
|
592
592
|
|
|
593
|
+
# Batches
|
|
594
|
+
def batches(limit: 100)
|
|
595
|
+
BatchEntry.order(created_at: :desc).limit(limit).map { |r| format_batch(r) }
|
|
596
|
+
rescue StandardError => e
|
|
597
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching batches: #{e.message}" }
|
|
598
|
+
[]
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def batch_detail(batch_id)
|
|
602
|
+
record = BatchEntry.find_by(batch_id: batch_id)
|
|
603
|
+
return nil unless record
|
|
604
|
+
|
|
605
|
+
format_batch(record).merge(
|
|
606
|
+
properties: record.properties,
|
|
607
|
+
on_finish_class: record.on_finish_class,
|
|
608
|
+
on_success_class: record.on_success_class,
|
|
609
|
+
on_discard_class: record.on_discard_class
|
|
610
|
+
)
|
|
611
|
+
rescue StandardError => e
|
|
612
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching batch #{batch_id}: #{e.message}" }
|
|
613
|
+
nil
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def batches_count
|
|
617
|
+
BatchEntry.count
|
|
618
|
+
rescue StandardError => e
|
|
619
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error counting batches: #{e.message}" }
|
|
620
|
+
0
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def active_batches_count
|
|
624
|
+
BatchEntry.where.not(status: "finished").count
|
|
625
|
+
rescue StandardError => e
|
|
626
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error counting active batches: #{e.message}" }
|
|
627
|
+
0
|
|
628
|
+
end
|
|
629
|
+
|
|
593
630
|
# Job stats
|
|
594
631
|
def job_stats_summary(minutes: 60)
|
|
595
632
|
JobStat.summary(minutes: minutes)
|
|
@@ -874,6 +911,33 @@ module Pgbus
|
|
|
874
911
|
[]
|
|
875
912
|
end
|
|
876
913
|
|
|
914
|
+
# NOTIFY throttle status for all queues with notifications enabled.
|
|
915
|
+
# Returns an array of hashes: { queue_name:, throttle_interval_ms:, last_notified_at: }
|
|
916
|
+
def notify_throttles
|
|
917
|
+
@client.list_notify_insert_throttles.map do |throttle|
|
|
918
|
+
{
|
|
919
|
+
queue_name: throttle.queue_name,
|
|
920
|
+
throttle_interval_ms: throttle.throttle_interval_ms,
|
|
921
|
+
last_notified_at: throttle.last_notified_at
|
|
922
|
+
}
|
|
923
|
+
end
|
|
924
|
+
rescue StandardError => e
|
|
925
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching notify throttles: #{e.message}" }
|
|
926
|
+
[]
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# FIFO group head sampling for a specific queue.
|
|
930
|
+
# Returns the oldest visible message from each distinct group (up to qty).
|
|
931
|
+
# Useful for detecting head-of-line stalls in multi-tenant queues.
|
|
932
|
+
def queue_group_heads(queue_name, qty: 20)
|
|
933
|
+
logical = logical_queue_name(queue_name)
|
|
934
|
+
messages = @client.read_grouped_head(logical, qty: qty) || []
|
|
935
|
+
messages.map { |m| format_pgmq_message(m, queue_name) }
|
|
936
|
+
rescue StandardError => e
|
|
937
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching group heads for #{queue_name}: #{e.message}" }
|
|
938
|
+
[]
|
|
939
|
+
end
|
|
940
|
+
|
|
877
941
|
private
|
|
878
942
|
|
|
879
943
|
def connection
|
|
@@ -1114,6 +1178,19 @@ module Pgbus
|
|
|
1114
1178
|
}
|
|
1115
1179
|
end
|
|
1116
1180
|
|
|
1181
|
+
def format_pgmq_message(msg, queue_name)
|
|
1182
|
+
{
|
|
1183
|
+
msg_id: msg.msg_id.to_i,
|
|
1184
|
+
read_ct: msg.read_ct.to_i,
|
|
1185
|
+
enqueued_at: msg.enqueued_at,
|
|
1186
|
+
last_read_at: msg.respond_to?(:last_read_at) ? msg.last_read_at : nil,
|
|
1187
|
+
vt: msg.respond_to?(:vt) ? msg.vt : nil,
|
|
1188
|
+
message: msg.message,
|
|
1189
|
+
headers: msg.headers,
|
|
1190
|
+
queue_name: queue_name
|
|
1191
|
+
}
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1117
1194
|
def format_process(row)
|
|
1118
1195
|
heartbeat = row["last_heartbeat_at"]
|
|
1119
1196
|
heartbeat_time = heartbeat.is_a?(String) ? Time.parse(heartbeat) : heartbeat
|
|
@@ -1131,6 +1208,25 @@ module Pgbus
|
|
|
1131
1208
|
}
|
|
1132
1209
|
end
|
|
1133
1210
|
|
|
1211
|
+
def format_batch(record)
|
|
1212
|
+
total = record.total_jobs
|
|
1213
|
+
done = record.completed_jobs + record.discarded_jobs
|
|
1214
|
+
pct = total.positive? ? ((done * 100) / total) : 100
|
|
1215
|
+
|
|
1216
|
+
{
|
|
1217
|
+
batch_id: record.batch_id,
|
|
1218
|
+
description: record.description,
|
|
1219
|
+
status: record.status,
|
|
1220
|
+
total_jobs: total,
|
|
1221
|
+
completed_jobs: record.completed_jobs,
|
|
1222
|
+
discarded_jobs: record.discarded_jobs,
|
|
1223
|
+
failed_jobs: record.failed_jobs,
|
|
1224
|
+
progress_pct: pct,
|
|
1225
|
+
created_at: record.created_at,
|
|
1226
|
+
finished_at: record.finished_at
|
|
1227
|
+
}
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1134
1230
|
def sanitize_name(name)
|
|
1135
1231
|
QueueNameValidator.sanitize!(name)
|
|
1136
1232
|
end
|
data/lib/pgbus/web/stream_app.rb
CHANGED
|
@@ -117,10 +117,11 @@ module Pgbus
|
|
|
117
117
|
hijack_result = env["rack.hijack"].call
|
|
118
118
|
io = hijack_result || env["rack.hijack_io"]
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
connection_id = SecureRandom.hex(8)
|
|
121
|
+
write_headers(io, stream_name: stream_name, since_id: since_id, connection_id: connection_id)
|
|
121
122
|
|
|
122
123
|
connection = Pgbus::Web::Streamer::Connection.new(
|
|
123
|
-
id:
|
|
124
|
+
id: connection_id,
|
|
124
125
|
stream_name: stream_name,
|
|
125
126
|
io: io,
|
|
126
127
|
since_id: since_id,
|
|
@@ -138,14 +139,16 @@ module Pgbus
|
|
|
138
139
|
require_relative "streamer/falcon_connection" unless defined?(Pgbus::Web::Streamer::FalconConnection)
|
|
139
140
|
|
|
140
141
|
body = ::Protocol::HTTP::Body::Writable.new
|
|
142
|
+
connection_id = SecureRandom.hex(8)
|
|
141
143
|
|
|
142
144
|
body.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
143
145
|
body.write(Pgbus::Streams::Envelope.comment(
|
|
144
146
|
"pgbus stream open since_id=#{since_id} stream=#{stream_name}"
|
|
145
147
|
))
|
|
148
|
+
body.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
146
149
|
|
|
147
150
|
connection = Pgbus::Web::Streamer::FalconConnection.new(
|
|
148
|
-
id:
|
|
151
|
+
id: connection_id,
|
|
149
152
|
stream_name: stream_name,
|
|
150
153
|
body: body,
|
|
151
154
|
since_id: since_id,
|
|
@@ -165,10 +168,11 @@ module Pgbus
|
|
|
165
168
|
}
|
|
166
169
|
end
|
|
167
170
|
|
|
168
|
-
def write_headers(io, stream_name:, since_id:)
|
|
171
|
+
def write_headers(io, stream_name:, since_id:, connection_id:)
|
|
169
172
|
io.write(Pgbus::Streams::Envelope.http_response_headers)
|
|
170
173
|
io.write(Pgbus::Streams::Envelope.retry_directive(2_000))
|
|
171
174
|
io.write(Pgbus::Streams::Envelope.comment("pgbus stream open since_id=#{since_id} stream=#{stream_name}"))
|
|
175
|
+
io.write(Pgbus::Streams::Envelope.connected(id: connection_id))
|
|
172
176
|
end
|
|
173
177
|
|
|
174
178
|
def streamer
|
|
@@ -14,6 +14,10 @@ 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, :last_msg_id_sent, :context
|
|
17
|
+
# The presence member id this connection auto-joined as, or nil for
|
|
18
|
+
# non-presence streams / anonymous connections. Set by the
|
|
19
|
+
# Dispatcher on connect; read on disconnect and heartbeat touch.
|
|
20
|
+
attr_accessor :presence_member
|
|
17
21
|
|
|
18
22
|
def initialize(id:, stream_name:, io:, since_id:, writer:, write_deadline_ms:, context: nil)
|
|
19
23
|
@id = id
|
|
@@ -25,6 +29,7 @@ module Pgbus
|
|
|
25
29
|
@mutex = Mutex.new
|
|
26
30
|
@dead = false
|
|
27
31
|
@closed = false
|
|
32
|
+
@presence_member = nil
|
|
28
33
|
@created_at = monotonic
|
|
29
34
|
@last_write_at = @created_at
|
|
30
35
|
# Context is whatever the StreamApp's authorize hook returned
|
|
@@ -43,7 +48,7 @@ module Pgbus
|
|
|
43
48
|
|
|
44
49
|
bytes = Pgbus::Streams::Envelope.message(
|
|
45
50
|
id: envelope.msg_id,
|
|
46
|
-
event:
|
|
51
|
+
event: sse_event_for(envelope),
|
|
47
52
|
data: envelope.payload
|
|
48
53
|
)
|
|
49
54
|
|
|
@@ -115,6 +120,15 @@ module Pgbus
|
|
|
115
120
|
|
|
116
121
|
private
|
|
117
122
|
|
|
123
|
+
# The SSE event name for a frame: the envelope's typed event when
|
|
124
|
+
# present, else the default turbo-stream. Plain ReadAfter::Envelopes
|
|
125
|
+
# (no event field) and StreamEnvelopes with a nil event both fall
|
|
126
|
+
# back to the default. (issue #170)
|
|
127
|
+
def sse_event_for(envelope)
|
|
128
|
+
event = envelope.respond_to?(:event) ? envelope.event : nil
|
|
129
|
+
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
130
|
+
end
|
|
131
|
+
|
|
118
132
|
def monotonic
|
|
119
133
|
# Qualify ::Process because Pgbus::Process already exists as a
|
|
120
134
|
# namespace for worker/supervisor/consumer and would otherwise
|
|
@@ -13,6 +13,7 @@ module Pgbus
|
|
|
13
13
|
# is fiber-safe under Falcon's scheduler.
|
|
14
14
|
class FalconConnection
|
|
15
15
|
attr_reader :id, :stream_name, :io, :mutex, :last_msg_id_sent, :context
|
|
16
|
+
attr_accessor :presence_member
|
|
16
17
|
|
|
17
18
|
def initialize(id:, stream_name:, body:, since_id:, write_deadline_ms:, context: nil)
|
|
18
19
|
@id = id
|
|
@@ -24,6 +25,7 @@ module Pgbus
|
|
|
24
25
|
@mutex = Mutex.new
|
|
25
26
|
@dead = false
|
|
26
27
|
@closed = false
|
|
28
|
+
@presence_member = nil
|
|
27
29
|
@created_at = monotonic
|
|
28
30
|
@last_write_at = @created_at
|
|
29
31
|
@context = context
|
|
@@ -36,7 +38,7 @@ module Pgbus
|
|
|
36
38
|
|
|
37
39
|
bytes = Pgbus::Streams::Envelope.message(
|
|
38
40
|
id: envelope.msg_id,
|
|
39
|
-
event:
|
|
41
|
+
event: sse_event_for(envelope),
|
|
40
42
|
data: envelope.payload
|
|
41
43
|
)
|
|
42
44
|
|
|
@@ -94,6 +96,12 @@ module Pgbus
|
|
|
94
96
|
|
|
95
97
|
private
|
|
96
98
|
|
|
99
|
+
# See Connection#sse_event_for. (issue #170)
|
|
100
|
+
def sse_event_for(envelope)
|
|
101
|
+
event = envelope.respond_to?(:event) ? envelope.event : nil
|
|
102
|
+
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
103
|
+
end
|
|
104
|
+
|
|
97
105
|
def write_to_body(bytes)
|
|
98
106
|
@mutex.synchronize do
|
|
99
107
|
return :closed if @dead || @body.closed?
|
|
@@ -61,6 +61,7 @@ module Pgbus
|
|
|
61
61
|
# code goes through the background thread.
|
|
62
62
|
def tick
|
|
63
63
|
now = @clock.call
|
|
64
|
+
presence_conns = []
|
|
64
65
|
@registry.each_connection do |connection|
|
|
65
66
|
if connection.dead?
|
|
66
67
|
# Already dead (e.g. IoWriter returned :closed on a previous
|
|
@@ -76,8 +77,15 @@ module Pgbus
|
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
result = connection.write_comment("heartbeat #{now.to_i}")
|
|
79
|
-
|
|
80
|
+
if connection.dead? || result != :ok
|
|
81
|
+
enqueue_disconnect(connection)
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
presence_conns << connection if presence_member?(connection)
|
|
80
86
|
end
|
|
87
|
+
|
|
88
|
+
enqueue_presence_touch(presence_conns)
|
|
81
89
|
end
|
|
82
90
|
|
|
83
91
|
private
|
|
@@ -99,6 +107,20 @@ module Pgbus
|
|
|
99
107
|
def enqueue_disconnect(connection)
|
|
100
108
|
@queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
|
|
101
109
|
end
|
|
110
|
+
|
|
111
|
+
def presence_member?(connection)
|
|
112
|
+
connection.respond_to?(:presence_member) && !connection.presence_member.nil?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Posts one batched touch per tick so the dispatcher (which owns AR
|
|
116
|
+
# connection release) refreshes last_seen_at for live presence
|
|
117
|
+
# members, keeping them out of the sweeper. No message when no
|
|
118
|
+
# connection has presence — avoids waking the dispatcher for nothing.
|
|
119
|
+
def enqueue_presence_touch(connections)
|
|
120
|
+
return if connections.empty?
|
|
121
|
+
|
|
122
|
+
@queue << StreamEventDispatcher::PresenceTouchMessage.new(connections: connections)
|
|
123
|
+
end
|
|
102
124
|
end
|
|
103
125
|
end
|
|
104
126
|
end
|