monkrb 0.18.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +2 -2
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +15 -3
- 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/scaffold.rb +155 -78
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +13 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +17 -2
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
- data/lib/monk/templates/jobs/bin/jobs +5 -13
- data/lib/monk/templates/jobs/config/jobs.rb +2 -4
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +23 -3
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +47 -0
- data/lib/monk/websocket/pg_fanout.rb +189 -31
- data/lib/monk/websocket/redis_fanout.rb +125 -33
- data/lib/monk/websocket/registry.rb +30 -0
- data/lib/monk/websocket/server.rb +128 -29
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +21 -9
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
- /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Served as-is and loaded as an ES module -- no build step. Import your
|
|
2
2
|
// own files by relative path with a real extension
|
|
3
3
|
// (import { x } from "./x.js"), and anything third-party through the
|
|
4
|
-
// import map in views/layouts/app.erb.
|
|
4
|
+
// import map in app/views/layouts/app.erb.
|
|
5
5
|
console.log("monk");
|
|
@@ -21,7 +21,8 @@ class SendLoginLink < Monk::Job
|
|
|
21
21
|
token = Monk::Auth.request_login(email, redirect_to: redirect_to)
|
|
22
22
|
# Your callback route; config/settings.rb's public_url is the origin.
|
|
23
23
|
link = "#{Monk::Settings[:public_url]}/auth/callback/#{token}"
|
|
24
|
-
# Calls config/auth.rb's deliver: -- a
|
|
24
|
+
# Calls config/auth.rb's deliver: (AppMailer::MAGIC_LINK) -- a
|
|
25
|
+
# synchronous send, here in the job.
|
|
25
26
|
Monk::Auth.deliver_link(email: email, link: link, token: token)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
require "bundler/setup"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
# treats config/auth.rb.
|
|
8
|
-
%w[mail auth].each do |name|
|
|
9
|
-
require_relative "../config/#{name}"
|
|
10
|
-
rescue LoadError
|
|
11
|
-
nil
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
require_relative "../config/jobs"
|
|
3
|
+
# The configs and app/, as the web process loads them, so a job can use
|
|
4
|
+
# the app's models and mailers, send mail, or push a Live update --
|
|
5
|
+
# config/load.rb, never Monk.boot: this process serves no routes.
|
|
6
|
+
require_relative "../config/load"
|
|
15
7
|
require "monk/jobs/runtime"
|
|
16
8
|
|
|
17
9
|
# The app's templates, so a job can render one (a mail's HTML part, say):
|
|
18
10
|
# there's no App class here to point Monk::Views at them.
|
|
19
|
-
Monk::Views.root = File.expand_path("../views", __dir__)
|
|
11
|
+
Monk::Views.root = File.expand_path("../app/views", __dir__)
|
|
20
12
|
|
|
21
13
|
workers = Integer(ENV.fetch("JOBS_WORKERS", "5"))
|
|
22
14
|
queues = ENV.fetch("JOBS_QUEUES", "default").split(",").map(&:strip)
|
|
@@ -4,8 +4,8 @@ require_relative "persistence"
|
|
|
4
4
|
|
|
5
5
|
# Background jobs (Monk::Jobs): enqueue from any route, e.g.
|
|
6
6
|
# HelloJob.enqueue("world"); bin/jobs runs them. Every job is a class under
|
|
7
|
-
# jobs/,
|
|
8
|
-
# run it.
|
|
7
|
+
# app/jobs/, which config/load.rb loads after this file, so the web process
|
|
8
|
+
# can enqueue it and bin/jobs can run it.
|
|
9
9
|
#
|
|
10
10
|
# The queue lives in the app's own database, so a job can be enqueued
|
|
11
11
|
# inside the app's own transaction: HelloJob.enqueue("world", conn: conn).
|
|
@@ -13,5 +13,3 @@ require_relative "persistence"
|
|
|
13
13
|
# a database of its own -- register it in config/persistence.rb and pass
|
|
14
14
|
# its name here (Monk's docs/guides/jobs.md, "Keeping the queue healthy").
|
|
15
15
|
Monk::Jobs.configure(db_name: :primary)
|
|
16
|
-
|
|
17
|
-
Dir[File.expand_path("../jobs/*.rb", __dir__)].sort.each { |file| require file }
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
require_relative "config/settings"
|
|
2
|
-
require_relative "config/live"
|
|
3
|
-
|
|
4
1
|
class App < Monk::Base
|
|
5
|
-
views "views"
|
|
2
|
+
views "app/views"
|
|
6
3
|
layout "layouts/app"
|
|
7
4
|
assets "public"
|
|
8
5
|
|
|
@@ -23,5 +20,3 @@ class App < Monk::Base
|
|
|
23
20
|
get("/hello") { "hello from monk" }
|
|
24
21
|
get("/api/hello") { json(message: "hello from monk") }
|
|
25
22
|
end
|
|
26
|
-
|
|
27
|
-
run Monk.boot(App)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<p>
|
|
14
14
|
The pieces: <code>live_topic</code> in this view, the partial
|
|
15
|
-
<code>views/live/_hits.erb</code>, <code>Monk::Live.patch</code> in
|
|
16
|
-
<code>POST /hit</code> (<code>
|
|
15
|
+
<code>app/views/live/_hits.erb</code>, <code>Monk::Live.patch</code> in
|
|
16
|
+
<code>POST /hit</code> (<code>app/app.rb</code>), and the rules and Redis
|
|
17
17
|
wiring in <code>config/live.rb</code>.
|
|
18
18
|
</p>
|
|
@@ -13,11 +13,29 @@ end
|
|
|
13
13
|
|
|
14
14
|
# Monk::Live: the browsers' sockets end here. The handler takes their
|
|
15
15
|
# subscribe/unsubscribe messages, checks each topic against the rules in
|
|
16
|
-
# config/live.rb, and relays whatever
|
|
16
|
+
# config/live.rb, and relays whatever bin/server's process publishes over
|
|
17
17
|
# Redis.
|
|
18
18
|
require_relative "../config/live"
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# This process holds the browsers' sockets, so it's the only one that
|
|
21
|
+
# listens for updates published elsewhere (bin/server, bin/jobs): the
|
|
22
|
+
# fanout's subscriber connection is opened here, at boot, and an
|
|
23
|
+
# unreachable Redis/Postgres fails right here.
|
|
24
|
+
Monk::Live.listen!
|
|
25
|
+
|
|
26
|
+
# With Monk::Auth (--auth), a socket with a valid session gets its user,
|
|
27
|
+
# and one without -- a visitor, or a session since revoked -- comes in
|
|
28
|
+
# anonymous instead of refused. The rules in config/live.rb decide what
|
|
29
|
+
# each may subscribe to: a topic is open to visitors only if its rule says
|
|
30
|
+
# `anonymous: true`, like the demo's "hits". Without Monk::Auth every
|
|
31
|
+
# socket is anonymous.
|
|
32
|
+
authenticate = defined?(Monk::Auth) && Monk::Auth.config ? :optional : false
|
|
33
|
+
# Sessions are checked through config/auth.rb's :auth pool rather than in
|
|
34
|
+
# each socket's own Ractor, which would hold a Postgres connection per
|
|
35
|
+
# open page. This is the only process that uses the pool, so it starts it.
|
|
36
|
+
db_pool = (:auth if authenticate)
|
|
37
|
+
Monk::Persistence::Pg.start_pools!(db_pool) if db_pool
|
|
38
|
+
|
|
21
39
|
port = ENV.fetch("WS_PORT", "9293").to_i
|
|
22
40
|
# Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
|
|
23
41
|
# keeps this in sync with the app's actual origin instead of two vars
|
|
@@ -25,6 +43,8 @@ port = ENV.fetch("WS_PORT", "9293").to_i
|
|
|
25
43
|
# more than one origin (comma-separated).
|
|
26
44
|
allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
|
|
27
45
|
|
|
28
|
-
server = Monk::WebSocket::Server.new(
|
|
46
|
+
server = Monk::WebSocket::Server.new(
|
|
47
|
+
port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
|
|
48
|
+
)
|
|
29
49
|
puts "Monk::Live WebSocket server listening on ws://0.0.0.0:#{port} (authenticate: #{authenticate})"
|
|
30
50
|
server.run(&Monk::Live::HANDLER)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Monk::Live wiring, required by both processes: config.
|
|
1
|
+
# Monk::Live wiring, required by both processes: config/load.rb (this app
|
|
2
2
|
# publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
|
|
3
3
|
# sockets terminate there and are handed the updates).
|
|
4
4
|
#
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Monk::Live wiring, required by both processes: config.
|
|
1
|
+
# Monk::Live wiring, required by both processes: config/load.rb (this app
|
|
2
2
|
# publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
|
|
3
3
|
# sockets terminate there and are handed the updates).
|
|
4
4
|
#
|
|
@@ -2,8 +2,8 @@ require "monk"
|
|
|
2
2
|
require "monk/mail"
|
|
3
3
|
|
|
4
4
|
# Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
|
|
5
|
-
# any route -- and, with --auth, the magic links
|
|
6
|
-
# delivers. See docs/guides/mail.md. Required from config.
|
|
5
|
+
# any route -- and, with --auth, the magic links AppMailer::MAGIC_LINK
|
|
6
|
+
# (app/mailers/app_mailer.rb) delivers. See docs/guides/mail.md. Required from config/load.rb (not from
|
|
7
7
|
# config/auth.rb: bin/websocket_server loads that too, and never sends mail).
|
|
8
8
|
Monk::Settings.configure do
|
|
9
9
|
# Where mail goes. Unset in development means log:// (printed to the
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
require "bundler/setup"
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# The configs and app/ (models, jobs, mailers...), as the app sees them --
|
|
4
|
+
# config/load.rb, never Monk.boot, so nothing is frozen and you can poke at
|
|
5
|
+
# anything.
|
|
6
|
+
require_relative "../config/load"
|
|
5
7
|
require "irb"
|
|
6
8
|
|
|
7
9
|
IRB.start(__FILE__)
|
|
File without changes
|
data/lib/monk/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Monk
|
|
|
5
5
|
# class of bug .freeze! guards against for routes/error handlers/models,
|
|
6
6
|
# just on a top-level constant that every worker Ractor reads on boot
|
|
7
7
|
# (found live under kino: GET /hello 500'd until this was frozen).
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.20.0".freeze
|
|
9
9
|
end
|
|
@@ -85,11 +85,22 @@ module Monk
|
|
|
85
85
|
|
|
86
86
|
# The server-initiated close path (step 14): send a close frame
|
|
87
87
|
# carrying the 2-byte status code + reason RFC 6455 expects, then
|
|
88
|
-
#
|
|
89
|
-
#
|
|
88
|
+
# end the connection -- no wait for the client's own close frame in
|
|
89
|
+
# reply.
|
|
90
|
+
#
|
|
91
|
+
# Shut down, not closed: this runs from other threads too (a relay
|
|
92
|
+
# on Registry#close_all, the reverify thread) while the handler's
|
|
93
|
+
# thread is blocked reading. IO#close would wait for that reader,
|
|
94
|
+
# and the reader's cleanup kills the closing thread mid-close,
|
|
95
|
+
# leaving the descriptor open: the client never sees the close.
|
|
96
|
+
# shutdown tells the client at once and ends the blocked read (it
|
|
97
|
+
# returns nil, as on any disconnect); Server.serve's ensure closes
|
|
98
|
+
# the socket.
|
|
90
99
|
def close(code: 1000, reason: "")
|
|
91
100
|
write_frame([code].pack("n") + reason.to_s, 0x8)
|
|
92
|
-
@socket.
|
|
101
|
+
@socket.shutdown(:RDWR)
|
|
102
|
+
rescue Errno::ENOTCONN
|
|
103
|
+
# The client went away first: nothing left to shut down.
|
|
93
104
|
end
|
|
94
105
|
|
|
95
106
|
# Registers this connection's own Ractor::Port -- the "connection
|
|
@@ -154,9 +165,17 @@ module Monk
|
|
|
154
165
|
private
|
|
155
166
|
|
|
156
167
|
def relay_broadcasts
|
|
157
|
-
loop
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
loop do
|
|
169
|
+
message = @port.receive
|
|
170
|
+
next write(message) unless message.is_a?(CloseRequest)
|
|
171
|
+
|
|
172
|
+
# Registry#close_all: the read loop then sees the socket closed
|
|
173
|
+
# and Server.serve's cleanup runs as on any other close.
|
|
174
|
+
return close(code: message.code, reason: message.reason)
|
|
175
|
+
end
|
|
176
|
+
rescue Ractor::ClosedError, IOError, SystemCallError
|
|
177
|
+
# #unsubscribe! closed the port out from under a pending #receive,
|
|
178
|
+
# or the socket went away first.
|
|
160
179
|
end
|
|
161
180
|
|
|
162
181
|
def write_frame(payload, opcode)
|
|
@@ -12,5 +12,16 @@ module Monk
|
|
|
12
12
|
# tradeoff.
|
|
13
13
|
class PayloadTooLargeError < StandardError
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# A fanout's #register before its #listen!: a socket registered there
|
|
17
|
+
# would never get another process's broadcasts, so this says so at the
|
|
18
|
+
# first subscription instead of losing them silently.
|
|
19
|
+
class NotListeningError < StandardError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# #listen! couldn't open its subscriber connection (Redis or Postgres
|
|
23
|
+
# unreachable, bad credentials) -- raised in the WS process's boot.
|
|
24
|
+
class ListenError < StandardError
|
|
25
|
+
end
|
|
15
26
|
end
|
|
16
27
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module WebSocket
|
|
3
|
+
# Which fanouts in this process have called #listen!, by origin id.
|
|
4
|
+
# A fanout freezes itself (every connection Ractor reads it through a
|
|
5
|
+
# module constant), so it can't record this on itself; a frozen list
|
|
6
|
+
# in a module ivar is written from the main Ractor, where #listen! runs
|
|
7
|
+
# at boot, and read from any Ractor, where #register runs.
|
|
8
|
+
#
|
|
9
|
+
# Also what both fanouts' subscribers share for surviving a dropped
|
|
10
|
+
# connection: the reconnect backoff and a log that can't end them.
|
|
11
|
+
module Listeners
|
|
12
|
+
# 500 ms doubling to 30 s, the same curve as monk_live.js's reconnect.
|
|
13
|
+
RETRY_INITIAL = 0.5
|
|
14
|
+
RETRY_MAX = 30
|
|
15
|
+
|
|
16
|
+
# What a subscriber closes every open socket with after reconnecting:
|
|
17
|
+
# what was published while it was down is lost, and no client can
|
|
18
|
+
# tell (its seq counts what this process sent it), so each must
|
|
19
|
+
# reconnect and resync (ADR 0011).
|
|
20
|
+
MISSED_CODE = 1011
|
|
21
|
+
MISSED_REASON = "missed broadcasts".freeze
|
|
22
|
+
|
|
23
|
+
@origins = [].freeze
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def listening?(origin) = @origins.include?(origin)
|
|
27
|
+
|
|
28
|
+
def next_delay(delay) = [delay * 2, RETRY_MAX].min
|
|
29
|
+
|
|
30
|
+
# Monk::Log writes to log/<env>.log once Monk has booted. Logging
|
|
31
|
+
# must never end a subscriber, whatever state Monk::Log is in.
|
|
32
|
+
def log(source, level, message)
|
|
33
|
+
Monk::Log.public_send(level, "#{source}: #{message}")
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Main Ractor only -- a module ivar write from another Ractor raises
|
|
39
|
+
# Ractor::IsolationError, which is the right answer for a boot-time
|
|
40
|
+
# call made from the wrong place.
|
|
41
|
+
def add(origin)
|
|
42
|
+
@origins = Ractor.make_shareable([*@origins, origin])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
require "pg"
|
|
2
2
|
require "securerandom"
|
|
3
|
+
require_relative "../persistence/pg"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "listeners"
|
|
3
6
|
|
|
4
7
|
module Monk
|
|
5
8
|
module WebSocket
|
|
@@ -7,7 +10,7 @@ module Monk
|
|
|
7
10
|
# "monk/websocket/pg_fanout" explicitly -- "monk/websocket" alone does
|
|
8
11
|
# not load this. A second implementation of the exact interface
|
|
9
12
|
# RedisFanout wraps a Registry behind (#register, #unregister, #count,
|
|
10
|
-
# #broadcast, docs/design/live-pg-fanout.md,
|
|
13
|
+
# #broadcast, #listen!, docs/design/live-pg-fanout.md,
|
|
11
14
|
# docs/history/plan-live-pg-fanout.md), so an app swaps which object it
|
|
12
15
|
# holds and nothing else changes. Uses Postgres LISTEN/NOTIFY instead
|
|
13
16
|
# of Redis pub/sub -- for an app that already runs Postgres
|
|
@@ -52,16 +55,115 @@ module Monk
|
|
|
52
55
|
[origin, key, payload]
|
|
53
56
|
end
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
162
|
+
# Checked upfront, on the argument -- RedisFanout has no equivalent
|
|
163
|
+
# check (freezing self never raises on its own, even when an ivar
|
|
164
|
+
# isn't itself shareable), so a bad registry there only surfaces
|
|
165
|
+
# later as an opaque Ractor::IsolationError. Mirrors
|
|
166
|
+
# Monk::Live.configure's own check of its registry argument.
|
|
65
167
|
unless Ractor.shareable?(registry)
|
|
66
168
|
raise ArgumentError,
|
|
67
169
|
"Monk::WebSocket::PgFanout.new needs a Ractor-shareable registry, got #{registry.class}"
|
|
@@ -76,37 +178,63 @@ module Monk
|
|
|
76
178
|
# fails on this Hash's contents, not on the registry argument it's
|
|
77
179
|
# meant to guard, the same trap Server#initialize's
|
|
78
180
|
# allowed_origins already avoids the same way.
|
|
79
|
-
|
|
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
|
|
80
187
|
# Frozen explicitly, not just a String literal: read from every
|
|
81
188
|
# calling Ractor's own #broadcast and from the subscriber Ractor
|
|
82
|
-
#
|
|
189
|
+
# #listen! starts, both of which raise Ractor::IsolationError on an
|
|
83
190
|
# unfrozen value -- same reason RedisFanout freezes its own
|
|
84
191
|
# @origin.
|
|
85
192
|
@origin = SecureRandom.uuid.freeze
|
|
86
193
|
|
|
194
|
+
freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Starts relaying other processes' broadcasts into this process's
|
|
198
|
+
# registry: a subscriber Ractor with its own LISTEN connection. Only
|
|
199
|
+
# the process that holds sockets calls this (bin/websocket_server, at
|
|
200
|
+
# boot, from the main Ractor) -- a publish-only process (bin/server,
|
|
201
|
+
# bin/jobs) never does, so it holds no LISTEN connection at all.
|
|
202
|
+
# Returns once the LISTEN is in effect, so nothing notified after it
|
|
203
|
+
# can be missed; raises ListenError if Postgres can't be reached.
|
|
204
|
+
# Calling it again is a no-op.
|
|
205
|
+
def listen!
|
|
206
|
+
return self if Listeners.listening?(@origin)
|
|
207
|
+
|
|
208
|
+
ready = Ractor::Port.new
|
|
87
209
|
# Ractor.new's block args must be shareable: registry is
|
|
88
210
|
# (Registry freezes itself around a Ractor, which is inherently
|
|
89
|
-
# shareable), @pg_opts and @origin are already made so
|
|
90
|
-
# PG::Connection itself is never passed in --
|
|
91
|
-
# the block, mirroring RedisFanout's
|
|
92
|
-
# Monk::Persistence::Pg's per-Ractor connection
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
loop do
|
|
97
|
-
conn.wait_for_notify do |_channel, _pid, raw|
|
|
98
|
-
sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
|
|
99
|
-
next if sender == own_origin
|
|
100
|
-
|
|
101
|
-
registry.broadcast(key.to_sym, payload)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
211
|
+
# shareable), @pg_opts and @origin are already made so, a Port
|
|
212
|
+
# always is. The PG::Connection itself is never passed in --
|
|
213
|
+
# opened fresh inside the block, mirroring RedisFanout's
|
|
214
|
+
# subscriber and Monk::Persistence::Pg's per-Ractor connection
|
|
215
|
+
# pattern.
|
|
216
|
+
Ractor.new(@registry, @pg_opts, @origin, ready) do |registry, pg_opts, own_origin, ready|
|
|
217
|
+
Monk::WebSocket::PgFanout.subscribe(registry, pg_opts, own_origin, ready)
|
|
104
218
|
end
|
|
105
219
|
|
|
106
|
-
|
|
220
|
+
result = ready.receive
|
|
221
|
+
raise ListenError, "Monk::WebSocket::PgFanout#listen! couldn't LISTEN: #{result}" unless result == :ok
|
|
222
|
+
|
|
223
|
+
Listeners.add(@origin)
|
|
224
|
+
self
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def register(key, port)
|
|
228
|
+
unless Listeners.listening?(@origin)
|
|
229
|
+
raise NotListeningError,
|
|
230
|
+
"Monk::WebSocket::PgFanout#register before #listen!: this socket would never get " \
|
|
231
|
+
"other processes' broadcasts -- call listen! once at boot in the process that holds " \
|
|
232
|
+
"sockets (bin/websocket_server: Monk::Live.listen!)"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@registry.register(key, port)
|
|
107
236
|
end
|
|
108
237
|
|
|
109
|
-
def register(key, port) = @registry.register(key, port)
|
|
110
238
|
def unregister(key, port) = @registry.unregister(key, port)
|
|
111
239
|
def count(key) = @registry.count(key)
|
|
112
240
|
|
|
@@ -131,11 +259,25 @@ module Monk
|
|
|
131
259
|
# parameter sidesteps any quoting/injection concern for its bytes
|
|
132
260
|
# entirely, unlike Redis's PUBLISH, which never had that concern
|
|
133
261
|
# in the first place.
|
|
262
|
+
return Monk::Persistence::Pg.pool(@db_pool).call_async(PgFanout, :notify, @db, envelope) if @db_pool
|
|
263
|
+
|
|
134
264
|
publisher.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope])
|
|
135
265
|
end
|
|
136
266
|
|
|
137
267
|
private
|
|
138
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
|
+
|
|
139
281
|
# Length-prefixes the origin and key (Netstring-style: "<bytesize>:"
|
|
140
282
|
# then that many bytes) instead of RedisFanout's NUL-separated
|
|
141
283
|
# "origin\0payload" -- discovered while building this that a NUL
|
|
@@ -166,8 +308,24 @@ module Monk
|
|
|
166
308
|
# out for app queries: NOTIFY issued on a connection mid-transaction
|
|
167
309
|
# wouldn't fire until that transaction commits, so this stays a
|
|
168
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.
|
|
169
318
|
def publisher
|
|
170
|
-
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)
|
|
171
329
|
end
|
|
172
330
|
end
|
|
173
331
|
end
|