pgbus 0.17.2 → 0.18.0
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 +10 -0
- data/README.md +55 -9
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +37 -4
- 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/pgbus/frontends_controller.rb +25 -11
- 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 +24 -6
- 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/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- 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/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration/stream_authorization.rb +36 -0
- data/lib/pgbus/configuration.rb +16 -1
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/streams/envelope.rb +3 -2
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/stream_app.rb +43 -14
- data/lib/pgbus/web/streamer/connection.rb +26 -9
- data/lib/pgbus/web/streamer/falcon_connection.rb +23 -9
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +25 -8
- data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +26 -39
- data/lib/pgbus.rb +2 -0
- metadata +15 -1
|
@@ -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
|
|
@@ -82,20 +88,21 @@ module Pgbus
|
|
|
82
88
|
end
|
|
83
89
|
|
|
84
90
|
def write_comment(text)
|
|
85
|
-
|
|
86
|
-
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
87
|
-
if result == :ok
|
|
88
|
-
@last_write_at = monotonic
|
|
89
|
-
else
|
|
90
|
-
mark_dead!
|
|
91
|
-
end
|
|
92
|
-
result
|
|
91
|
+
write_frame(Pgbus::Streams::Envelope.comment(text))
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
def write_sentinel(bytes)
|
|
96
95
|
@writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
97
96
|
end
|
|
98
97
|
|
|
98
|
+
# The pgbus:connected frame carrying this connection's id. Written by
|
|
99
|
+
# the Dispatcher once the connection is registered and its presence
|
|
100
|
+
# seat joined (issue #521), so a client that acts on the id right
|
|
101
|
+
# after the event finds its row.
|
|
102
|
+
def write_connected
|
|
103
|
+
write_frame(Pgbus::Streams::Envelope.connected(id: @id))
|
|
104
|
+
end
|
|
105
|
+
|
|
99
106
|
def idle_for
|
|
100
107
|
monotonic - @last_write_at
|
|
101
108
|
end
|
|
@@ -136,6 +143,16 @@ module Pgbus
|
|
|
136
143
|
|
|
137
144
|
private
|
|
138
145
|
|
|
146
|
+
def write_frame(bytes)
|
|
147
|
+
result = @writer.write(self, bytes, deadline_ms: @write_deadline_ms)
|
|
148
|
+
if result == :ok
|
|
149
|
+
@last_write_at = monotonic
|
|
150
|
+
else
|
|
151
|
+
mark_dead!
|
|
152
|
+
end
|
|
153
|
+
result
|
|
154
|
+
end
|
|
155
|
+
|
|
139
156
|
# The SSE event name for a frame: the envelope's typed event when
|
|
140
157
|
# present, else the default turbo-stream. Plain ReadAfter::Envelopes
|
|
141
158
|
# (no event field) and StreamEnvelopes with a nil event both fall
|
|
@@ -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
|
|
@@ -68,14 +74,12 @@ module Pgbus
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def write_comment(text)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
end
|
|
78
|
-
result
|
|
77
|
+
write_frame(Pgbus::Streams::Envelope.comment(text))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# See Connection#write_connected (issue #521).
|
|
81
|
+
def write_connected
|
|
82
|
+
write_frame(Pgbus::Streams::Envelope.connected(id: @id))
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
def write_sentinel(bytes)
|
|
@@ -114,6 +118,16 @@ module Pgbus
|
|
|
114
118
|
event && !event.to_s.empty? ? event.to_s : Pgbus::Streams::DEFAULT_SSE_EVENT
|
|
115
119
|
end
|
|
116
120
|
|
|
121
|
+
def write_frame(bytes)
|
|
122
|
+
result = write_to_body(bytes)
|
|
123
|
+
if result == :ok
|
|
124
|
+
@last_write_at = monotonic
|
|
125
|
+
else
|
|
126
|
+
mark_dead!
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
117
131
|
def write_to_body(bytes)
|
|
118
132
|
@mutex.synchronize do
|
|
119
133
|
return :closed if @dead || @body.closed?
|
|
@@ -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
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
module Pgbus
|
|
4
4
|
module Web
|
|
5
5
|
module Streamer
|
|
6
|
-
# Composes the Streamer's
|
|
7
|
-
# Heartbeat
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Composes the Streamer's background threads (Listener, Dispatcher,
|
|
7
|
+
# Heartbeat, and the PeerCloseWatcher unless streams_peer_close_interval
|
|
8
|
+
# is nil) with the shared Registry and dispatch_queue. One Instance per
|
|
9
|
+
# Puma worker. Owns the lifecycle of those threads and the dedicated
|
|
10
|
+
# PG::Connection for LISTEN.
|
|
10
11
|
#
|
|
11
12
|
# Lifecycle:
|
|
12
13
|
# Instance.new(...) — allocates wiring, does NOT start threads
|
|
13
|
-
# #start — spawns listener/dispatcher/heartbeat in order
|
|
14
|
+
# #start — spawns listener/dispatcher/heartbeat/watcher in order
|
|
14
15
|
# #register(conn) — enqueues a ConnectMessage into the dispatch queue
|
|
15
16
|
# #shutdown! — sends shutdown sentinel to every connection and
|
|
16
17
|
# stops all threads in reverse order
|
|
@@ -21,7 +22,7 @@ module Pgbus
|
|
|
21
22
|
# defaults from the configuration.
|
|
22
23
|
class Instance
|
|
23
24
|
attr_reader :registry, :listener, :dispatcher, :heartbeat, :dispatch_queue, :stream_counter, :pump,
|
|
24
|
-
:autoscaler
|
|
25
|
+
:autoscaler, :peer_close_watcher
|
|
25
26
|
|
|
26
27
|
def initialize(
|
|
27
28
|
client: Pgbus.client,
|
|
@@ -74,8 +75,10 @@ module Pgbus
|
|
|
74
75
|
dispatch_queue: @dispatch_queue,
|
|
75
76
|
interval: @config.streams_heartbeat_interval,
|
|
76
77
|
idle_timeout: @config.streams_idle_timeout,
|
|
77
|
-
logger: @logger
|
|
78
|
+
logger: @logger,
|
|
79
|
+
presence_expiry: Array(@config.streams_presence_patterns).any?
|
|
78
80
|
)
|
|
81
|
+
@peer_close_watcher = build_peer_close_watcher
|
|
79
82
|
@started = false
|
|
80
83
|
@shutdown_mutex = Mutex.new
|
|
81
84
|
end
|
|
@@ -90,6 +93,7 @@ module Pgbus
|
|
|
90
93
|
@listener.start
|
|
91
94
|
@dispatcher.start
|
|
92
95
|
@heartbeat.start
|
|
96
|
+
@peer_close_watcher&.start
|
|
93
97
|
self
|
|
94
98
|
end
|
|
95
99
|
|
|
@@ -118,7 +122,9 @@ module Pgbus
|
|
|
118
122
|
end
|
|
119
123
|
|
|
120
124
|
# Graceful shutdown for Puma worker restart. Order matters:
|
|
121
|
-
#
|
|
125
|
+
# 0. Peer-close watcher first (issue #519): stop reaping sockets
|
|
126
|
+
# we're about to close ourselves; #stop wakes its select at once
|
|
127
|
+
# 1. Heartbeat next (stop writing comments to connections we're
|
|
122
128
|
# about to close)
|
|
123
129
|
# 2. Listener next (stop accepting new NOTIFYs)
|
|
124
130
|
# 3. Dispatcher next (drain the queue; it's now finite because
|
|
@@ -149,6 +155,7 @@ module Pgbus
|
|
|
149
155
|
|
|
150
156
|
@started = false
|
|
151
157
|
threads = component_threads
|
|
158
|
+
safely { @peer_close_watcher&.stop }
|
|
152
159
|
safely { @heartbeat.stop }
|
|
153
160
|
safely { @listener.stop }
|
|
154
161
|
safely { @dispatcher.stop }
|
|
@@ -241,6 +248,7 @@ module Pgbus
|
|
|
241
248
|
def component_threads
|
|
242
249
|
{
|
|
243
250
|
"heartbeat" => @heartbeat.threads,
|
|
251
|
+
"peer_close_watcher" => @peer_close_watcher ? @peer_close_watcher.threads : [],
|
|
244
252
|
"listener" => @listener.threads,
|
|
245
253
|
"dispatcher" => @dispatcher.threads,
|
|
246
254
|
"pump" => @pump ? @pump.threads : []
|
|
@@ -275,6 +283,15 @@ module Pgbus
|
|
|
275
283
|
)
|
|
276
284
|
end
|
|
277
285
|
|
|
286
|
+
# Notices a closed SSE client at once instead of on the next
|
|
287
|
+
# heartbeat write (issue #519). nil interval: heartbeat only.
|
|
288
|
+
def build_peer_close_watcher
|
|
289
|
+
interval = @config.streams_peer_close_interval
|
|
290
|
+
return nil unless interval
|
|
291
|
+
|
|
292
|
+
PeerCloseWatcher.new(registry: @registry, dispatch_queue: @dispatch_queue, interval: interval, logger: @logger)
|
|
293
|
+
end
|
|
294
|
+
|
|
278
295
|
def build_pump
|
|
279
296
|
threads = @config.streams_writer_threads
|
|
280
297
|
return nil unless threads.positive?
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
module Streamer
|
|
6
|
+
# Notices an SSE client that went away the moment its socket says so,
|
|
7
|
+
# instead of on the next heartbeat write (issue #519).
|
|
8
|
+
#
|
|
9
|
+
# The server never reads a hijacked SSE socket after the request, so a
|
|
10
|
+
# closed tab used to stay registered (and present) until a heartbeat
|
|
11
|
+
# write failed, up to streams_heartbeat_interval later. Often longer:
|
|
12
|
+
# the first write after the client's FIN still succeeds. The client
|
|
13
|
+
# sends nothing after its request, so the socket only turns readable
|
|
14
|
+
# when the peer closes or resets it. This thread waits on every live
|
|
15
|
+
# socket with IO.select and probes the readable ones with a
|
|
16
|
+
# non-blocking read. EOF or a reset means the client is gone: the
|
|
17
|
+
# connection is marked dead, closed, and handed to the dispatcher's
|
|
18
|
+
# disconnect path, the same DisconnectMessage the heartbeat posts. That
|
|
19
|
+
# path is idempotent, so a heartbeat racing the watcher still leaves
|
|
20
|
+
# the seat once.
|
|
21
|
+
#
|
|
22
|
+
# Only connections with a real socket are watched: Puma's and Falcon's
|
|
23
|
+
# rack.hijack paths. Falcon's experimental streaming-body path
|
|
24
|
+
# (FalconConnection) exposes no socket and keeps the heartbeat.
|
|
25
|
+
#
|
|
26
|
+
# Half-close: a client that shuts down only its write side (SHUT_WR)
|
|
27
|
+
# reads as EOF and is reaped. EventSource, fetch streams and reverse
|
|
28
|
+
# proxies never half-close an SSE request, so EOF means the client left.
|
|
29
|
+
#
|
|
30
|
+
# The probe takes the connection's write mutex with try_lock: a TLS
|
|
31
|
+
# wrapper (Puma's MiniSSL) is not safe to read while IoWriter writes
|
|
32
|
+
# through it. A socket mid-write is skipped and probed on a later pass.
|
|
33
|
+
#
|
|
34
|
+
# `interval` bounds how long a new connection waits to join the
|
|
35
|
+
# watched set; a close on a watched socket wakes the select at once,
|
|
36
|
+
# and #stop wakes it through a self-pipe.
|
|
37
|
+
class PeerCloseWatcher
|
|
38
|
+
# Stray bytes from a client are read and discarded in chunks of this.
|
|
39
|
+
DRAIN_BYTES = 4096
|
|
40
|
+
|
|
41
|
+
# How long #stop waits for the thread. It is woken at once, so this
|
|
42
|
+
# only bounds a reap stuck closing a socket (see Heartbeat#stop).
|
|
43
|
+
STOP_JOIN_SECONDS = 5
|
|
44
|
+
|
|
45
|
+
def initialize(registry:, dispatch_queue:, interval: 1.0, logger: Pgbus.logger)
|
|
46
|
+
@registry = registry
|
|
47
|
+
@queue = dispatch_queue
|
|
48
|
+
@interval = interval
|
|
49
|
+
@logger = logger
|
|
50
|
+
@running = false
|
|
51
|
+
@thread = nil
|
|
52
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start
|
|
56
|
+
return if @running
|
|
57
|
+
|
|
58
|
+
@wake_reader, @wake_writer = IO.pipe if @wake_reader.closed?
|
|
59
|
+
@running = true
|
|
60
|
+
@thread = Thread.new { run_loop }
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# See Heartbeat#threads (issue #443).
|
|
65
|
+
def threads
|
|
66
|
+
[@thread].compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stop
|
|
70
|
+
return unless @running
|
|
71
|
+
|
|
72
|
+
@running = false
|
|
73
|
+
wake
|
|
74
|
+
@thread&.join(STOP_JOIN_SECONDS)
|
|
75
|
+
@thread = nil
|
|
76
|
+
@wake_reader.close
|
|
77
|
+
@wake_writer.close
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One wait-and-probe pass, at most `interval` long. Public for tests.
|
|
82
|
+
def tick
|
|
83
|
+
by_socket = watched_sockets
|
|
84
|
+
ready, = IO.select([@wake_reader, *by_socket.keys], nil, nil, @interval)
|
|
85
|
+
Array(ready).each do |socket|
|
|
86
|
+
socket.equal?(@wake_reader) ? drain_wake : probe(by_socket[socket])
|
|
87
|
+
end
|
|
88
|
+
rescue IOError, Errno::EBADF => e
|
|
89
|
+
# A socket was closed under the select (shutdown, a dispatcher
|
|
90
|
+
# write that failed). The next pass rebuilds the set without it.
|
|
91
|
+
@logger.debug { "[Pgbus::Streamer::PeerCloseWatcher] select interrupted: #{e.class}: #{e.message}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def wake
|
|
97
|
+
@wake_writer.write_nonblock(".", exception: false)
|
|
98
|
+
rescue IOError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def drain_wake
|
|
103
|
+
@wake_reader.read_nonblock(64, exception: false)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One socket's probe never ends the pass for the others.
|
|
107
|
+
def probe(connection)
|
|
108
|
+
return unless connection.mutex.try_lock
|
|
109
|
+
|
|
110
|
+
closed = begin
|
|
111
|
+
peer_closed?(connection)
|
|
112
|
+
ensure
|
|
113
|
+
connection.mutex.unlock
|
|
114
|
+
end
|
|
115
|
+
reap(connection) if closed
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@logger.error { "[Pgbus::Streamer::PeerCloseWatcher] probe of #{connection.id} raised: #{e.class}: #{e.message}" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_loop
|
|
121
|
+
while @running
|
|
122
|
+
begin
|
|
123
|
+
tick
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@logger.error { "[Pgbus::Streamer::PeerCloseWatcher] tick raised: #{e.class}: #{e.message}" }
|
|
126
|
+
sleep @interval
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# select needs a real IO: Puma's TLS socket and Falcon's hijacked
|
|
132
|
+
# stream answer #to_io with the underlying socket.
|
|
133
|
+
def watched_sockets
|
|
134
|
+
sockets = {}
|
|
135
|
+
@registry.each_connection do |connection|
|
|
136
|
+
next if connection.dead?
|
|
137
|
+
|
|
138
|
+
socket = socket_of(connection)
|
|
139
|
+
sockets[socket] = connection if socket
|
|
140
|
+
end
|
|
141
|
+
sockets
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def socket_of(connection)
|
|
145
|
+
io = connection.io
|
|
146
|
+
return nil unless io.respond_to?(:to_io) && io.respond_to?(:read_nonblock)
|
|
147
|
+
|
|
148
|
+
socket = io.to_io
|
|
149
|
+
socket if socket.is_a?(::BasicSocket) && !socket.closed?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def peer_closed?(connection)
|
|
153
|
+
# A nil read is EOF; :wait_readable (TLS, a spurious wake) and stray
|
|
154
|
+
# bytes leave the connection alone.
|
|
155
|
+
connection.io.read_nonblock(DRAIN_BYTES, exception: false).nil?
|
|
156
|
+
rescue IO::WaitReadable
|
|
157
|
+
# A socket wrapper that raises instead of honouring exception:
|
|
158
|
+
# false (EAGAIN is a SystemCallError, so this rescue comes first).
|
|
159
|
+
false
|
|
160
|
+
rescue IOError, SystemCallError, *tls_errors
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A TLS socket whose client vanished without close_notify raises its
|
|
165
|
+
# library's SSL error ("unexpected eof while reading") on read.
|
|
166
|
+
def tls_errors
|
|
167
|
+
errors = []
|
|
168
|
+
errors << ::OpenSSL::SSL::SSLError if defined?(::OpenSSL::SSL::SSLError)
|
|
169
|
+
errors << ::Puma::MiniSSL::SSLError if defined?(::Puma::MiniSSL::SSLError)
|
|
170
|
+
errors
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def reap(connection)
|
|
174
|
+
return if connection.dead?
|
|
175
|
+
|
|
176
|
+
connection.mark_dead!
|
|
177
|
+
connection.close
|
|
178
|
+
@queue << StreamEventDispatcher::DisconnectMessage.new(connection: connection)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -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
|