monkrb 0.19.0 → 0.21.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -1,5 +1,6 @@
1
1
  require "pg"
2
2
  require "securerandom"
3
+ require_relative "../persistence/pg"
3
4
  require_relative "errors"
4
5
  require_relative "listeners"
5
6
 
@@ -54,7 +55,110 @@ module Monk
54
55
  [origin, key, payload]
55
56
  end
56
57
 
57
- def initialize(registry, pg_opts:)
58
+ # The body of #listen!'s subscriber Ractor. A class method, like
59
+ # .decode_envelope, because the Ractor has no PgFanout instance to
60
+ # call into. Reports :ok, or why it couldn't LISTEN, on `ready`, then
61
+ # relays notifies for the rest of the process's life.
62
+ #
63
+ # A dropped LISTEN connection (a Postgres restart, a failover) is
64
+ # reconnected with backoff (plan-pg-reconnect.md). Notifies sent
65
+ # while it was down are lost, so once listening again every open
66
+ # socket is closed (Listeners::MISSED_CODE), and each client
67
+ # reconnects and resyncs; their jittered reconnect spreads the
68
+ # refetches.
69
+ def self.subscribe(registry, pg_opts, own_origin, ready)
70
+ begin
71
+ conn = listen_connection(pg_opts)
72
+ rescue StandardError => e
73
+ ready.send("#{e.class}: #{e.message}")
74
+ return
75
+ end
76
+ ready.send(:ok)
77
+
78
+ loop do
79
+ conn.wait_for_notify { |_channel, _pid, raw| relay(registry, own_origin, raw) }
80
+ rescue PG::ConnectionBad => e
81
+ log(:error, "lost the LISTEN connection (#{e.message.lines.first&.strip}); reconnecting")
82
+ conn = reconnect(pg_opts)
83
+ closed = registry.close_all(Listeners::MISSED_CODE, Listeners::MISSED_REASON)
84
+ log(:info, "listening again; closed #{closed} socket(s) so their pages resync")
85
+ end
86
+ end
87
+
88
+ # One notify into this process's registry. Anything that goes wrong
89
+ # here (a payload that isn't an envelope, a failing broadcast) drops
90
+ # this notify only: escaping, it would end the subscriber Ractor, and
91
+ # with it every later broadcast, silently.
92
+ def self.relay(registry, own_origin, raw)
93
+ sender, key, payload = decode_envelope(raw)
94
+ return if sender == own_origin
95
+
96
+ registry.broadcast(key.to_sym, payload)
97
+ rescue StandardError => e
98
+ log(:error, "dropped a notify: #{e.class}: #{e.message}")
99
+ end
100
+
101
+ def self.listen_connection(pg_opts)
102
+ conn = PG.connect(connect_timeout: Monk::Persistence::Pg::DEFAULT_CONNECT_TIMEOUT, **pg_opts)
103
+ conn.exec("LISTEN #{CHANNEL}")
104
+ conn
105
+ rescue StandardError
106
+ conn&.close
107
+ raise
108
+ end
109
+
110
+ def self.reconnect(pg_opts)
111
+ delay = Listeners::RETRY_INITIAL
112
+ begin
113
+ listen_connection(pg_opts)
114
+ rescue PG::Error => e
115
+ log(:warn, "can't LISTEN yet (#{e.class}: #{e.message.lines.first&.strip}); retrying in #{delay}s")
116
+ sleep delay
117
+ delay = Listeners.next_delay(delay)
118
+ retry
119
+ end
120
+ end
121
+
122
+ def self.log(level, message) = Listeners.log(name, level, message)
123
+ private_class_method :relay, :listen_connection, :reconnect, :log
124
+
125
+ # The body of a pooled publish (#broadcast with db_pool:), run in the
126
+ # pool's worker on its connection. That connection serves nothing
127
+ # but pool calls, each of which ends its transaction, so a NOTIFY
128
+ # here is never held back by an app's open transaction.
129
+ def self.notify(db, envelope)
130
+ Monk::Persistence::Pg.checkout(db) { |conn| conn.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope]) }
131
+ nil
132
+ end
133
+
134
+ # Closes the calling Ractor's publisher connection (#publisher), if
135
+ # it opened one; the next broadcast in this Ractor reconnects.
136
+ # Server.serve runs it as each socket ends: a Ractor that ends
137
+ # leaves its connection to the garbage collector, which may not run
138
+ # for a long time.
139
+ def self.disconnect_publisher
140
+ conn = Ractor.current[:monk_pg_fanout_publisher]
141
+ return unless conn
142
+
143
+ Ractor.current[:monk_pg_fanout_publisher] = nil
144
+ conn.finish
145
+ rescue PG::Error
146
+ nil # already closed
147
+ end
148
+
149
+ # pg_opts: connects the publisher (one connection per Ractor that
150
+ # publishes) and #listen!'s LISTEN connection. db_pool: (a
151
+ # Monk::Persistence::Pg pool of size 1, docs/history/plan-pg-pool.md)
152
+ # publishes through that pool instead, so a process holds one
153
+ # publishing connection however many Ractors publish, and LISTENs on
154
+ # the pool's database. Exactly one of the two.
155
+ #
156
+ # Through the pool a publish doesn't wait for Postgres (call_async):
157
+ # a failed pg_notify is logged, not raised, and the page catches up
158
+ # at its next resync, as it would after any missed patch. Size 1
159
+ # because only a single worker runs one sender's publishes in the
160
+ # order they were made, and patches to a page must arrive in order.
161
+ def initialize(registry, pg_opts: nil, db_pool: nil)
58
162
  # Checked upfront, on the argument -- RedisFanout has no equivalent
59
163
  # check (freezing self never raises on its own, even when an ivar
60
164
  # isn't itself shareable), so a bad registry there only surfaces
@@ -74,7 +178,12 @@ module Monk
74
178
  # fails on this Hash's contents, not on the registry argument it's
75
179
  # meant to guard, the same trap Server#initialize's
76
180
  # allowed_origins already avoids the same way.
77
- @pg_opts = Ractor.make_shareable(pg_opts.dup)
181
+ raise ArgumentError, "Monk::WebSocket::PgFanout.new needs pg_opts: or db_pool:, not both" if pg_opts && db_pool
182
+ raise ArgumentError, "Monk::WebSocket::PgFanout.new needs pg_opts: or db_pool:" unless pg_opts || db_pool
183
+
184
+ @pg_opts = Ractor.make_shareable((pg_opts || pooled_pg_opts(db_pool)).dup)
185
+ @db_pool = db_pool
186
+ @db = db_pool && Monk::Persistence::Pg.pool_config(db_pool).db
78
187
  # Frozen explicitly, not just a String literal: read from every
79
188
  # calling Ractor's own #broadcast and from the subscriber Ractor
80
189
  # #listen! starts, both of which raise Ractor::IsolationError on an
@@ -105,23 +214,7 @@ module Monk
105
214
  # subscriber and Monk::Persistence::Pg's per-Ractor connection
106
215
  # pattern.
107
216
  Ractor.new(@registry, @pg_opts, @origin, ready) do |registry, pg_opts, own_origin, ready|
108
- begin
109
- conn = PG.connect(**pg_opts)
110
- conn.exec("LISTEN #{Monk::WebSocket::PgFanout::CHANNEL}")
111
- rescue StandardError => e
112
- ready.send("#{e.class}: #{e.message}")
113
- next
114
- end
115
- ready.send(:ok)
116
-
117
- loop do
118
- conn.wait_for_notify do |_channel, _pid, raw|
119
- sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
120
- next if sender == own_origin
121
-
122
- registry.broadcast(key.to_sym, payload)
123
- end
124
- end
217
+ Monk::WebSocket::PgFanout.subscribe(registry, pg_opts, own_origin, ready)
125
218
  end
126
219
 
127
220
  result = ready.receive
@@ -166,11 +259,25 @@ module Monk
166
259
  # parameter sidesteps any quoting/injection concern for its bytes
167
260
  # entirely, unlike Redis's PUBLISH, which never had that concern
168
261
  # in the first place.
262
+ return Monk::Persistence::Pg.pool(@db_pool).call_async(PgFanout, :notify, @db, envelope) if @db_pool
263
+
169
264
  publisher.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope])
170
265
  end
171
266
 
172
267
  private
173
268
 
269
+ # The pool's database's connection options, for #listen!.
270
+ def pooled_pg_opts(db_pool)
271
+ config = Monk::Persistence::Pg.pool_config(db_pool)
272
+ unless config.size == 1
273
+ raise ArgumentError,
274
+ "Monk::WebSocket::PgFanout's db_pool #{db_pool.inspect} has size #{config.size}; it must have size 1: " \
275
+ "patches to a page must arrive in the order they were published, and only one worker keeps " \
276
+ "a sender's publishes in order"
277
+ end
278
+ Monk::Persistence::Pg.connection_options(config.db)
279
+ end
280
+
174
281
  # Length-prefixes the origin and key (Netstring-style: "<bytesize>:"
175
282
  # then that many bytes) instead of RedisFanout's NUL-separated
176
283
  # "origin\0payload" -- discovered while building this that a NUL
@@ -201,8 +308,24 @@ module Monk
201
308
  # out for app queries: NOTIFY issued on a connection mid-transaction
202
309
  # wouldn't fire until that transaction commits, so this stays a
203
310
  # connection of its own, used for nothing but one-off notifies.
311
+ #
312
+ # Probed before each notify and reset if Postgres dropped it
313
+ # (Monk::Persistence::Pg.alive?, plan-pg-reconnect.md), the way
314
+ # checkout treats an app's connection. A failed pg_notify is never
315
+ # retried: it may have reached the server, and a patch delivered
316
+ # twice shows twice, while a missed one is recovered by the client's
317
+ # resync.
204
318
  def publisher
205
- Ractor.current[:monk_pg_fanout_publisher] ||= PG.connect(**@pg_opts)
319
+ conn = Ractor.current[:monk_pg_fanout_publisher] ||= connect
320
+ Monk::Persistence::Pg.revive(conn) unless Monk::Persistence::Pg.alive?(conn)
321
+ conn
322
+ end
323
+
324
+ # Same connect_timeout default as Monk::Persistence::Pg's own
325
+ # connections: a reset runs inside a request's Live.patch, and
326
+ # mustn't wait indefinitely for a host that doesn't answer.
327
+ def connect
328
+ PG.connect(connect_timeout: Monk::Persistence::Pg::DEFAULT_CONNECT_TIMEOUT, **@pg_opts)
206
329
  end
207
330
  end
208
331
  end
@@ -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
@@ -240,7 +271,9 @@ module Monk
240
271
  return true if credential&.fetch(:via) == :bearer
241
272
 
242
273
  origin = headers["origin"]
243
- allowed = credential ? origin && allowed_origins.include?(origin) : origin.nil? || allowed_origins.include?(origin)
274
+ # A cookie needs an allowed Origin; without one, only a non-browser
275
+ # client (no Origin at all) is let in.
276
+ allowed = origin.nil? ? !credential : allowed_origins.include?(origin)
244
277
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") unless allowed
245
278
  allowed
246
279
  end
@@ -248,9 +281,9 @@ module Monk
248
281
 
249
282
  # The subject of a valid session, or nil -- no credential, or one
250
283
  # Monk::Auth no longer accepts, both mean anonymous under :optional.
251
- def self.identify(headers)
284
+ def self.identify(headers, db_pool)
252
285
  credential = Handshake.credential_from(headers)
253
- credential && Monk::Auth.verify(credential[:token])
286
+ credential && verify(credential[:token], db_pool)
254
287
  end
255
288
  private_class_method :identify
256
289
 
@@ -263,11 +296,11 @@ module Monk
263
296
  # own Thread rather than blocking the handler's own read loop,
264
297
  # mirroring Connection#start_heartbeat's shape; killed from #serve's
265
298
  # ensure the same way.
266
- def self.start_reverify(connection, raw_token, interval)
299
+ def self.start_reverify(connection, raw_token, interval, db_pool)
267
300
  Thread.new do
268
301
  loop do
269
302
  sleep interval
270
- break unless Monk::Auth.verify(raw_token)
303
+ break unless verify(raw_token, db_pool)
271
304
  end
272
305
  connection.close(code: 1008, reason: "session no longer valid")
273
306
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
@@ -278,6 +311,14 @@ module Monk
278
311
  end
279
312
  private_class_method :start_reverify
280
313
 
314
+ # Monk::Auth.verify, here or in the db_pool: pool (Server.new).
315
+ def self.verify(raw_token, db_pool)
316
+ return Monk::Auth.verify(raw_token) unless db_pool
317
+
318
+ Monk::Persistence::Pg.pool(db_pool).call(Monk::Auth, :verify, raw_token)
319
+ end
320
+ private_class_method :verify
321
+
281
322
  def self.read_handshake_request(socket)
282
323
  request = +""
283
324
  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