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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +55 -9
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +37 -4
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  9. data/app/frontend/pgbus/application.min.js +2 -0
  10. data/app/frontend/pgbus/application.min.js.map +1 -0
  11. data/app/frontend/pgbus/modules/charts.js +24 -6
  12. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  13. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  14. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  15. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  16. data/app/views/layouts/pgbus/application.html.erb +0 -1
  17. data/config/routes.rb +1 -1
  18. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  19. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  20. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  21. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  22. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  23. data/lib/pgbus/configuration/stream_authorization.rb +36 -0
  24. data/lib/pgbus/configuration.rb +16 -1
  25. data/lib/pgbus/engine.rb +13 -7
  26. data/lib/pgbus/generators/migration_detector.rb +9 -1
  27. data/lib/pgbus/streams/envelope.rb +3 -2
  28. data/lib/pgbus/streams/presence.rb +317 -81
  29. data/lib/pgbus/version.rb +1 -1
  30. data/lib/pgbus/web/stream_app.rb +43 -14
  31. data/lib/pgbus/web/streamer/connection.rb +26 -9
  32. data/lib/pgbus/web/streamer/falcon_connection.rb +23 -9
  33. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  34. data/lib/pgbus/web/streamer/instance.rb +25 -8
  35. data/lib/pgbus/web/streamer/peer_close_watcher.rb +183 -0
  36. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  37. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +26 -39
  38. data/lib/pgbus.rb +2 -0
  39. 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
- bytes = Pgbus::Streams::Envelope.comment(text)
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
- bytes = Pgbus::Streams::Envelope.comment(text)
72
- result = write_to_body(bytes)
73
- if result == :ok
74
- @last_write_at = monotonic
75
- else
76
- mark_dead!
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
- def initialize(registry:, dispatch_queue:, interval:, idle_timeout:, logger: Pgbus.logger, clock: nil)
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 dispatcher for nothing.
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 three background threads (Listener, Dispatcher,
7
- # Heartbeat) with the shared Registry and dispatch_queue. One Instance
8
- # per Puma worker. Owns the lifecycle of all three threads and the
9
- # dedicated PG::Connection for LISTEN.
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
- # 1. Heartbeat first (stop writing comments to connections we're
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