monkrb 0.19.0 → 0.20.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.
@@ -38,6 +38,83 @@ module Monk
38
38
  # already shareable.
39
39
  CHANNEL_PREFIX = "monk:ws:".freeze
40
40
 
41
+ # The body of #listen!'s subscriber Ractor. A class method because
42
+ # the Ractor has no RedisFanout instance to call into. Reports :ok,
43
+ # or why it couldn't subscribe, on `ready`, then relays messages for
44
+ # the rest of the process's life.
45
+ #
46
+ # A dropped connection (a Redis restart, a failover) is resubscribed
47
+ # with backoff. Messages published while it was down are lost, so
48
+ # once subscribed again every open socket is closed
49
+ # (Listeners::MISSED_CODE), and each client reconnects and resyncs.
50
+ def self.subscribe(registry, url, own_origin, ready)
51
+ listening = false
52
+ delay = Listeners::RETRY_INITIAL
53
+ loop do
54
+ Redis.new(url: url).psubscribe("#{CHANNEL_PREFIX}*") do |on|
55
+ on.psubscribe do
56
+ delay = Listeners::RETRY_INITIAL
57
+ if listening
58
+ closed = registry.close_all(Listeners::MISSED_CODE, Listeners::MISSED_REASON)
59
+ log(:info, "subscribed again; closed #{closed} socket(s) so their pages resync")
60
+ else
61
+ listening = true
62
+ ready.send(:ok)
63
+ end
64
+ end
65
+ on.pmessage { |_pattern, channel, envelope| relay(registry, own_origin, channel, envelope) }
66
+ end
67
+ rescue StandardError => e
68
+ # Not narrower: redis-rb lets redis-client's own
69
+ # RedisClient::ConnectionError out of psubscribe, not a
70
+ # Redis::BaseConnectionError. #relay rescues its own errors, so
71
+ # what gets here is the connection.
72
+ return ready.send("#{e.class}: #{e.message}") unless listening
73
+
74
+ log(:error, "subscriber connection failed (#{e.class}: #{e.message}); retrying in #{delay}s")
75
+ sleep delay
76
+ delay = Listeners.next_delay(delay)
77
+ end
78
+ end
79
+
80
+ # One message into this process's registry. Anything that goes wrong
81
+ # here drops this message only: escaping, it would end the
82
+ # subscriber Ractor, and with it every later broadcast, silently.
83
+ #
84
+ # Redis channel names are always Strings, but Registry's own keys
85
+ # are ordinary Hash keys the app chooses -- every existing caller
86
+ # (Registry's own tests, the websocket_server scaffold's :chat
87
+ # channel) uses a Symbol. #to_sym here is what makes
88
+ # registry.broadcast(:chat, ...) land on the same Hash key
89
+ # #register(:chat, port) used -- without it this silently
90
+ # broadcasts to an unrelated "chat" String key that nothing is ever
91
+ # registered under, and #broadcast still returns true either way
92
+ # (Registry#broadcast succeeds whether or not any port is registered
93
+ # for a key).
94
+ def self.relay(registry, own_origin, channel, envelope)
95
+ sender, payload = envelope.split("\0", 2)
96
+ raise ArgumentError, "no origin separator in the message" unless payload
97
+ return if sender == own_origin
98
+
99
+ registry.broadcast(channel.delete_prefix(CHANNEL_PREFIX).to_sym, payload)
100
+ rescue StandardError => e
101
+ log(:error, "dropped a message on #{channel}: #{e.class}: #{e.message}")
102
+ end
103
+
104
+ def self.log(level, message) = Listeners.log(name, level, message)
105
+ private_class_method :relay, :log
106
+
107
+ # Closes the calling Ractor's publisher client (#publisher), if it
108
+ # opened one; the next broadcast in this Ractor reconnects.
109
+ # Server.serve runs it as each socket ends, as PgFanout's.
110
+ def self.disconnect_publisher
111
+ client = Ractor.current[:monk_redis_fanout_publisher]
112
+ return unless client
113
+
114
+ Ractor.current[:monk_redis_fanout_publisher] = nil
115
+ client.close
116
+ end
117
+
41
118
  def initialize(registry, redis_url:)
42
119
  @registry = registry
43
120
  @redis_url = redis_url.dup.freeze
@@ -73,35 +150,7 @@ module Monk
73
150
  # Redis client itself is built inside the block, never passed in --
74
151
  # mirrors PG::Connection's per-Ractor pattern (persistence/pg.rb).
75
152
  Ractor.new(@registry, @redis_url, @origin, ready) do |registry, url, origin, ready|
76
- subscribed = false
77
- Redis.new(url: url).psubscribe("#{Monk::WebSocket::RedisFanout::CHANNEL_PREFIX}*") do |on|
78
- on.psubscribe do
79
- subscribed = true
80
- ready.send(:ok)
81
- end
82
-
83
- on.pmessage do |_pattern, channel, envelope|
84
- sender, payload = envelope.split("\0", 2)
85
- next if sender == origin
86
-
87
- # Redis channel names are always Strings, but Registry's own
88
- # keys are ordinary Hash keys the app chooses -- every
89
- # existing caller (Registry's own tests, the websocket_server
90
- # scaffold's :chat channel) uses a Symbol. #to_sym here is
91
- # what makes registry.broadcast(:chat, ...) land on the same
92
- # Hash key #register(:chat, port) used -- without it this
93
- # silently broadcasts to an unrelated "chat" String key that
94
- # nothing is ever registered under, and #broadcast still
95
- # returns true either way (Registry#broadcast succeeds
96
- # whether or not any port is registered for a key).
97
- key = channel.delete_prefix(Monk::WebSocket::RedisFanout::CHANNEL_PREFIX).to_sym
98
- registry.broadcast(key, payload)
99
- end
100
- end
101
- rescue StandardError => e
102
- raise if subscribed
103
-
104
- ready.send("#{e.class}: #{e.message}")
153
+ Monk::WebSocket::RedisFanout.subscribe(registry, url, origin, ready)
105
154
  end
106
155
 
107
156
  result = ready.receive
@@ -1,5 +1,10 @@
1
1
  module Monk
2
2
  module WebSocket
3
+ # Sent through a registered port instead of a payload: the socket
4
+ # behind it closes with this code and reason. Registry#close_all sends
5
+ # it; Connection's and Live::Session's relays act on it.
6
+ CloseRequest = Data.define(:code, :reason)
7
+
3
8
  # A dedicated Ractor holding the live set of connection handles keyed by
4
9
  # an app-assigned channel/subject -- mirrors StateRactor's shape
5
10
  # (docs/design/ractor.md): the mutable Hash stays hidden inside this one
@@ -41,6 +46,17 @@ module Monk
41
46
  true
42
47
  when :count
43
48
  keys[key].size
49
+ when :close_all
50
+ # Once per port: a Live page holds one port for all its
51
+ # topics. Entries stay until each connection's own cleanup
52
+ # unregisters them, as on any other close.
53
+ ports = keys.values.flatten.uniq
54
+ ports.each do |port|
55
+ port.send(arg)
56
+ rescue Ractor::ClosedError
57
+ nil
58
+ end
59
+ ports.size
44
60
  end
45
61
  reply_port.send(result)
46
62
  end
@@ -67,6 +83,14 @@ module Monk
67
83
  ask(:count, key, nil)
68
84
  end
69
85
 
86
+ # Closes every registered connection's socket with code and reason,
87
+ # so each client reconnects -- a fanout's subscriber does this after
88
+ # missing broadcasts, to make every page resync. Returns how many
89
+ # connections were asked to close.
90
+ def close_all(code, reason)
91
+ ask(:close_all, nil, Ractor.make_shareable(CloseRequest.new(code: code, reason: reason.to_s)))
92
+ end
93
+
70
94
  # Nothing to listen to: every broadcast reaching this registry comes
71
95
  # from this process. Here so a WS process calls #listen! the same
72
96
  # way whether it holds a Registry or a RedisFanout/PgFanout around
@@ -40,6 +40,15 @@ module Monk
40
40
  # under true, and to an anonymous connection that sends an Origin,
41
41
  # since only a browser does.
42
42
  #
43
+ # db_pool: (a pool name, docs/history/plan-pg-pool.md) runs those
44
+ # Monk::Auth.verify calls -- the handshake's and reverify_interval's
45
+ # -- in that Monk::Persistence::Pg pool instead of in the socket's
46
+ # own Ractor. Without it, each socket's Ractor opens a connection of
47
+ # its own on the first verify and holds it while the socket is open:
48
+ # one Postgres connection per open page. With it, the server holds
49
+ # the pool's. Requires authenticate:, and the pool started
50
+ # (start_pools!) before the server.
51
+ #
43
52
  # max_payload_size: (bytes) -- see Connection::DEFAULT_MAX_PAYLOAD_SIZE
44
53
  # for what it guards against (gap 5). Unlike ping_interval:/
45
54
  # reverify_interval:, always on: this is insurance against a
@@ -53,7 +62,7 @@ module Monk
53
62
 
54
63
  def initialize(
55
64
  port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
56
- reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
65
+ reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
57
66
  )
58
67
  if ping_interval && !ping_interval.positive?
59
68
  raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
@@ -70,6 +79,8 @@ module Monk
70
79
  raise ArgumentError, "reverify_interval requires authenticate: true or :optional" unless authenticate
71
80
  end
72
81
 
82
+ raise ArgumentError, "db_pool requires authenticate: true or :optional" if db_pool && !authenticate
83
+
73
84
  unless max_payload_size.positive?
74
85
  raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
75
86
  end
@@ -91,12 +102,17 @@ module Monk
91
102
  Monk.freeze!
92
103
  end
93
104
 
105
+ # Raises now, not on the first connection, if the pool was never
106
+ # declared or isn't started in this process.
107
+ Monk::Persistence::Pg.pool(db_pool) if db_pool
108
+
94
109
  @tcp_server = TCPServer.new(bind, port)
95
110
  @allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
96
111
  @authenticate = authenticate
97
112
  @ping_interval = ping_interval
98
113
  @reverify_interval = reverify_interval
99
114
  @max_payload_size = max_payload_size
115
+ @db_pool = db_pool
100
116
  end
101
117
 
102
118
  def port
@@ -117,12 +133,13 @@ module Monk
117
133
  loop do
118
134
  socket = @tcp_server.accept
119
135
  ractor = Ractor.new(
120
- shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size
121
- ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size|
136
+ shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size,
137
+ @db_pool
138
+ ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size, db_pool|
122
139
  Monk::WebSocket::Server.serve(
123
140
  Ractor.receive, blk,
124
- authenticate: authenticate, allowed_origins: origins,
125
- ping_interval: ping_interval, reverify_interval: reverify_interval, max_payload_size: max_payload_size
141
+ authenticate: authenticate, allowed_origins: origins, ping_interval: ping_interval,
142
+ reverify_interval: reverify_interval, max_payload_size: max_payload_size, db_pool: db_pool
126
143
  )
127
144
  end
128
145
  ractor.send(socket, move: true)
@@ -152,7 +169,7 @@ module Monk
152
169
  # TCPServer) is never Ractor-shareable and can't cross into here.
153
170
  def self.serve(
154
171
  socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
155
- max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
172
+ max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE, db_pool: nil
156
173
  )
157
174
  request = read_handshake_request(socket)
158
175
  return socket.close unless request
@@ -167,12 +184,12 @@ module Monk
167
184
  subject = nil
168
185
  case authenticate
169
186
  when true
170
- subject = authenticate!(socket, headers, allowed_origins)
187
+ subject = authenticate!(socket, headers, allowed_origins, db_pool)
171
188
  return unless subject
172
189
  when :optional
173
190
  return unless origin_allowed_for_optional?(socket, headers, allowed_origins)
174
191
 
175
- subject = identify(headers)
192
+ subject = identify(headers, db_pool)
176
193
  end
177
194
 
178
195
  socket.write(Handshake.response_for(request))
@@ -181,7 +198,7 @@ module Monk
181
198
  connection.start_heartbeat(ping_interval) if ping_interval
182
199
  reverify_thread =
183
200
  if reverify_interval && subject
184
- start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval)
201
+ start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval, db_pool)
185
202
  end
186
203
  begin
187
204
  block.call(connection)
@@ -201,7 +218,21 @@ module Monk
201
218
  connection&.unsubscribe!
202
219
  connection&.stop_heartbeat!
203
220
  socket.close
221
+ disconnect_all
222
+ end
223
+
224
+ # Closes the connections this socket's Ractor opened: its database
225
+ # connection (verifying the session without db_pool:, or a block
226
+ # that queries) and a fan-out's publisher (a block that broadcasts).
227
+ # Left alone, each would stay open until the garbage collector
228
+ # happened to run, which a mostly idle server may not do for a long
229
+ # time. The fan-outs are opt-in, so each is closed only if loaded.
230
+ def self.disconnect_all
231
+ Monk::Persistence.disconnect_all
232
+ PgFanout.disconnect_publisher if defined?(PgFanout)
233
+ RedisFanout.disconnect_publisher if defined?(RedisFanout)
204
234
  end
235
+ private_class_method :disconnect_all
205
236
 
206
237
  # docs/history/plan-websocket.md steps 22-23: the Origin allowlist check runs
207
238
  # before Monk::Auth.verify, and only for a cookie-derived
@@ -209,7 +240,7 @@ module Monk
209
240
  # by construction (mirrors require_csrf!'s own Bearer exemption in
210
241
  # docs/history/plan-auth.md). Returns the verified subject, or nil after writing
211
242
  # the appropriate 403/401 response itself.
212
- def self.authenticate!(socket, headers, allowed_origins)
243
+ def self.authenticate!(socket, headers, allowed_origins, db_pool)
213
244
  credential = Handshake.credential_from(headers)
214
245
 
215
246
  if credential&.fetch(:via) == :cookie && allowed_origins
@@ -220,7 +251,7 @@ module Monk
220
251
  end
221
252
  end
222
253
 
223
- subject = credential && Monk::Auth.verify(credential[:token])
254
+ subject = credential && verify(credential[:token], db_pool)
224
255
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
225
256
  subject
226
257
  end
@@ -248,9 +279,9 @@ module Monk
248
279
 
249
280
  # The subject of a valid session, or nil -- no credential, or one
250
281
  # Monk::Auth no longer accepts, both mean anonymous under :optional.
251
- def self.identify(headers)
282
+ def self.identify(headers, db_pool)
252
283
  credential = Handshake.credential_from(headers)
253
- credential && Monk::Auth.verify(credential[:token])
284
+ credential && verify(credential[:token], db_pool)
254
285
  end
255
286
  private_class_method :identify
256
287
 
@@ -263,11 +294,11 @@ module Monk
263
294
  # own Thread rather than blocking the handler's own read loop,
264
295
  # mirroring Connection#start_heartbeat's shape; killed from #serve's
265
296
  # ensure the same way.
266
- def self.start_reverify(connection, raw_token, interval)
297
+ def self.start_reverify(connection, raw_token, interval, db_pool)
267
298
  Thread.new do
268
299
  loop do
269
300
  sleep interval
270
- break unless Monk::Auth.verify(raw_token)
301
+ break unless verify(raw_token, db_pool)
271
302
  end
272
303
  connection.close(code: 1008, reason: "session no longer valid")
273
304
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
@@ -278,6 +309,14 @@ module Monk
278
309
  end
279
310
  private_class_method :start_reverify
280
311
 
312
+ # Monk::Auth.verify, here or in the db_pool: pool (Server.new).
313
+ def self.verify(raw_token, db_pool)
314
+ return Monk::Auth.verify(raw_token) unless db_pool
315
+
316
+ Monk::Persistence::Pg.pool(db_pool).call(Monk::Auth, :verify, raw_token)
317
+ end
318
+ private_class_method :verify
319
+
281
320
  def self.read_handshake_request(socket)
282
321
  request = +""
283
322
  until request.end_with?("\r\n\r\n")
@@ -3,6 +3,8 @@ require_relative "errors"
3
3
  # true) always raises this precise error, per ADR 0003, rather than a bare
4
4
  # NameError when an app calls it without ever requiring "monk/auth" at all.
5
5
  require_relative "auth/errors"
6
+ # Dependency-free too: Server.serve closes a socket Ractor's connections.
7
+ require_relative "persistence"
6
8
  require_relative "websocket/errors"
7
9
  require_relative "websocket/handshake"
8
10
  require_relative "websocket/frame"
data/lib/monk.rb CHANGED
@@ -24,6 +24,8 @@ module Monk
24
24
  parts << " - auth=on" if defined?(Auth) && Auth.config
25
25
  backends = persistence_backends
26
26
  parts << " - persistence=#{backends.join(",")}" unless backends.empty?
27
+ pools = running_pools
28
+ parts << " - pools=#{pools.join(",")}" unless pools.empty?
27
29
  parts << "\n"
28
30
 
29
31
  $stdout.puts parts.join("\n")
@@ -42,4 +44,12 @@ module Monk
42
44
  .filter_map { |hook| "#{hook.name.split("::").last.downcase}:#{hook.names.join(",")}" if hook.names.any? }
43
45
  end
44
46
  private_class_method :persistence_backends
47
+
48
+ # "name(size)" for every pool started in this process, by any backend.
49
+ def self.running_pools
50
+ freeze_hooks
51
+ .select { |hook| hook.is_a?(Module) && hook.singleton_class.include?(Persistence::Registry) }
52
+ .flat_map { |hook| hook.running_pools.map { |name, size| "#{name}(#{size})" } }
53
+ end
54
+ private_class_method :running_pools
45
55
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin
@@ -212,6 +212,7 @@ files:
212
212
  - lib/monk/persistence/pg/errors.rb
213
213
  - lib/monk/persistence/pg/migrator.rb
214
214
  - lib/monk/persistence/pg/model.rb
215
+ - lib/monk/persistence/pool.rb
215
216
  - lib/monk/scaffold.rb
216
217
  - lib/monk/settings.rb
217
218
  - lib/monk/state_ractor.rb
@@ -261,6 +262,7 @@ files:
261
262
  - lib/monk/templates/postgres/bin/migrate
262
263
  - lib/monk/templates/postgres/bin/setup_db
263
264
  - lib/monk/templates/postgres/config/persistence.rb
265
+ - lib/monk/templates/postgres/db/migrate/.keep
264
266
  - lib/monk/templates/redis/Gemfile.extra
265
267
  - lib/monk/version.rb
266
268
  - lib/monk/views.rb