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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +7 -6
- data/exe/monk +3 -181
- data/lib/monk/assets.rb +39 -9
- data/lib/monk/auth/errors.rb +3 -0
- data/lib/monk/auth/helpers.rb +11 -0
- data/lib/monk/auth/send_login_link.rb +36 -0
- data/lib/monk/auth.rb +34 -5
- data/lib/monk/base.rb +20 -0
- data/lib/monk/bin.rb +107 -0
- data/lib/monk/cli.rb +277 -0
- data/lib/monk/context.rb +17 -0
- data/lib/monk/errors.rb +0 -6
- data/lib/monk/generator/definition.rb +153 -0
- data/lib/monk/generator/result.rb +279 -0
- data/lib/monk/generator/run.rb +449 -0
- data/lib/monk/generator.rb +48 -0
- data/lib/monk/generators/auth.rb +29 -0
- data/lib/monk/generators/base.rb +19 -0
- data/lib/monk/generators/jobs.rb +21 -0
- data/lib/monk/generators/live.rb +26 -0
- data/lib/monk/generators/mail.rb +24 -0
- data/lib/monk/generators/postgres.rb +23 -0
- data/lib/monk/generators/redis.rb +20 -0
- data/lib/monk/generators/websocket.rb +24 -0
- data/lib/monk/generators.rb +7 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +17 -4
- data/lib/monk/log.rb +10 -1
- data/lib/monk/mail/deliver_later.rb +54 -0
- data/lib/monk/mail/later.rb +3 -51
- data/lib/monk/mail.rb +4 -0
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/templates/auth/agents.md +29 -0
- data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
- data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
- data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
- data/lib/monk/templates/auth/config/auth.rb +14 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +4 -3
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +8 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +57 -16
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +83 -14
- data/lib/monk/scaffold.rb +0 -1061
- data/lib/monk/templates/base/bin/websocket_server +0 -68
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
- data/lib/monk/templates/live/app/app.rb +0 -22
- data/lib/monk/templates/live/app/views/index.erb +0 -18
- data/lib/monk/templates/live/bin/websocket_server +0 -42
- data/lib/monk/templates/live/config/live_pg.rb +0 -60
- data/lib/monk/templates/mail/Gemfile.extra +0 -1
- data/lib/monk/templates/postgres/Dockerfile +0 -32
- data/lib/monk/templates/postgres/Gemfile.extra +0 -2
- data/lib/monk/templates/redis/Gemfile.extra +0 -1
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ||=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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")
|
data/lib/monk/websocket.rb
CHANGED
|
@@ -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
|