monkrb 0.18.0 → 0.19.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 +77 -0
- data/README.md +2 -2
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live.rb +10 -0
- data/lib/monk/scaffold.rb +152 -77
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +7 -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 +6 -0
- 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 +14 -2
- 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/version.rb +1 -1
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +23 -0
- data/lib/monk/websocket/pg_fanout.rb +55 -20
- data/lib/monk/websocket/redis_fanout.rb +57 -14
- data/lib/monk/websocket/registry.rb +6 -0
- data/lib/monk/websocket/server.rb +77 -17
- metadata +19 -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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The emails this app sends, through Monk::Mail (config/mail.rb). Module
|
|
2
|
+
# constants and module methods rather than an instance, since Monk::Auth's
|
|
3
|
+
# deliver: hook must be Ractor-shareable (a lambda built at a file's top
|
|
4
|
+
# level, where self isn't, would fail) -- the same constraint as
|
|
5
|
+
# Monk::Live.authorize blocks.
|
|
6
|
+
module AppMailer
|
|
7
|
+
# Monk::Auth's deliver: hook (config/auth.rb), called by
|
|
8
|
+
# Monk::Auth.deliver_link. The HTML part is
|
|
9
|
+
# app/views/mail/magic_link.erb. Swap the body for SMS or any other
|
|
10
|
+
# channel; see docs/guides/auth.md.
|
|
11
|
+
MAGIC_LINK = lambda do |email:, link:, token:|
|
|
12
|
+
# Development only (a no-op elsewhere): the link on the console, plus a
|
|
13
|
+
# QR code for a phone if rqrcode is in the Gemfile.
|
|
14
|
+
Monk::Auth.log_dev_link(link, subject: email)
|
|
15
|
+
Monk::Mail.deliver(
|
|
16
|
+
to: email,
|
|
17
|
+
subject: "Your login link",
|
|
18
|
+
text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
|
|
19
|
+
html: Monk::Mail.render("mail/magic_link", link: link),
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -7,25 +7,12 @@ require_relative "persistence"
|
|
|
7
7
|
# "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
|
|
8
8
|
# there and auth.md's "Sending the magic link".
|
|
9
9
|
|
|
10
|
-
# Sends the magic link by email
|
|
11
|
-
#
|
|
12
|
-
# Auth.configure
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
module AppMailer
|
|
17
|
-
DELIVER = lambda do |email:, link:, token:|
|
|
18
|
-
# Development only (a no-op elsewhere): the link on the console, plus a
|
|
19
|
-
# QR code for a phone if rqrcode is in the Gemfile.
|
|
20
|
-
Monk::Auth.log_dev_link(link, subject: email)
|
|
21
|
-
Monk::Mail.deliver(
|
|
22
|
-
to: email,
|
|
23
|
-
subject: "Your login link",
|
|
24
|
-
text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
|
|
25
|
-
html: Monk::Mail.render("mail/magic_link", link: link),
|
|
26
|
-
)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
10
|
+
# Sends the magic link by email: AppMailer::MAGIC_LINK, in
|
|
11
|
+
# app/mailers/app_mailer.rb. Required here, not left to config/load.rb:
|
|
12
|
+
# Monk::Auth.configure checks deliver: right away, and bin/websocket_server
|
|
13
|
+
# loads this file without config/load.rb. The mailer only touches
|
|
14
|
+
# Monk::Mail when it's called, so that process needs no mail config.
|
|
15
|
+
require_relative "../app/mailers/app_mailer"
|
|
29
16
|
|
|
30
17
|
Monk::Auth.configure(
|
|
31
18
|
db_name: :primary,
|
|
@@ -34,5 +21,5 @@ Monk::Auth.configure(
|
|
|
34
21
|
session_ttl: 1_209_600, # seconds a session stays valid (14 days)
|
|
35
22
|
redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
|
|
36
23
|
secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
|
|
37
|
-
deliver: AppMailer::
|
|
24
|
+
deliver: AppMailer::MAGIC_LINK,
|
|
38
25
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -48,6 +48,12 @@ module ChatServer
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# This process holds the sockets, so it's the one that listens for other
|
|
52
|
+
# processes' broadcasts: with REDIS_URL, a subscriber connection, opened
|
|
53
|
+
# here at boot (an unreachable Redis fails right here). A plain Registry
|
|
54
|
+
# has nothing to listen to, and listen! does nothing.
|
|
55
|
+
ChatServer::REGISTRY.listen!
|
|
56
|
+
|
|
51
57
|
authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
|
|
52
58
|
port = ENV.fetch("WS_PORT", "9293").to_i
|
|
53
59
|
# Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Loads everything the app runs on: the settings, each Monk module's config,
|
|
2
|
+
# then the app's own code under app/. Required by config.ru and the tests;
|
|
3
|
+
# it never boots the app itself (that's config.ru's Monk.boot).
|
|
4
|
+
require_relative "settings"
|
|
5
|
+
|
|
6
|
+
# The app's code, one directory per role, in the order they call each
|
|
7
|
+
# other: presenters read models, helpers, mailers and broadcasts use both,
|
|
8
|
+
# and jobs call all of them. Everything is loaded here, before Monk.boot
|
|
9
|
+
# freezes the app -- nothing can be loaded later from a worker Ractor.
|
|
10
|
+
#
|
|
11
|
+
# The order only matters for code that runs while a file loads (a
|
|
12
|
+
# superclass, a constant in a class body, a Monk::Context.include): such a
|
|
13
|
+
# file require_relative's what it needs first. Code inside methods runs
|
|
14
|
+
# later, so it may use any role, even one loaded after it. See Monk's
|
|
15
|
+
# docs/guides/scaffolding.md, "Where code goes".
|
|
16
|
+
%w[models presenters helpers mailers broadcasts jobs].each do |role|
|
|
17
|
+
Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
|
|
18
|
+
end
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
require_relative "config/
|
|
1
|
+
require_relative "config/load" # the configs and app/ (config/load.rb)
|
|
2
|
+
require_relative "app/app"
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
views "views"
|
|
5
|
-
layout "layouts/app"
|
|
6
|
-
assets "public"
|
|
7
|
-
|
|
8
|
-
get("/") { @title = "App"; render "index" }
|
|
9
|
-
get("/hello") { "hello from monk" }
|
|
10
|
-
get("/api/hello") { json(message: "hello from monk") }
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
run Monk.boot(App)
|
|
4
|
+
run Monk.boot(App) # Boot: freezes the app and serves it
|
|
@@ -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,23 @@ 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
|
|
21
33
|
port = ENV.fetch("WS_PORT", "9293").to_i
|
|
22
34
|
# Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
|
|
23
35
|
# keeps this in sync with the app's actual origin instead of two vars
|
|
@@ -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__)
|
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.19.0".freeze
|
|
9
9
|
end
|
|
@@ -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,23 @@
|
|
|
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
|
+
module Listeners
|
|
9
|
+
@origins = [].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def listening?(origin) = @origins.include?(origin)
|
|
13
|
+
|
|
14
|
+
# Main Ractor only -- a module ivar write from another Ractor raises
|
|
15
|
+
# Ractor::IsolationError, which is the right answer for a boot-time
|
|
16
|
+
# call made from the wrong place.
|
|
17
|
+
def add(origin)
|
|
18
|
+
@origins = Ractor.make_shareable([*@origins, origin])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "pg"
|
|
2
2
|
require "securerandom"
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "listeners"
|
|
3
5
|
|
|
4
6
|
module Monk
|
|
5
7
|
module WebSocket
|
|
@@ -7,7 +9,7 @@ module Monk
|
|
|
7
9
|
# "monk/websocket/pg_fanout" explicitly -- "monk/websocket" alone does
|
|
8
10
|
# not load this. A second implementation of the exact interface
|
|
9
11
|
# RedisFanout wraps a Registry behind (#register, #unregister, #count,
|
|
10
|
-
# #broadcast, docs/design/live-pg-fanout.md,
|
|
12
|
+
# #broadcast, #listen!, docs/design/live-pg-fanout.md,
|
|
11
13
|
# docs/history/plan-live-pg-fanout.md), so an app swaps which object it
|
|
12
14
|
# holds and nothing else changes. Uses Postgres LISTEN/NOTIFY instead
|
|
13
15
|
# of Redis pub/sub -- for an app that already runs Postgres
|
|
@@ -53,15 +55,11 @@ module Monk
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def initialize(registry, pg_opts:)
|
|
56
|
-
# Checked upfront, on the argument
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# than after spawning the subscriber below, means a bad registry
|
|
62
|
-
# never leaves a live LISTEN connection running past a failed
|
|
63
|
-
# construction. Mirrors Monk::Live.configure's own check of its
|
|
64
|
-
# registry argument.
|
|
58
|
+
# Checked upfront, on the argument -- RedisFanout has no equivalent
|
|
59
|
+
# check (freezing self never raises on its own, even when an ivar
|
|
60
|
+
# isn't itself shareable), so a bad registry there only surfaces
|
|
61
|
+
# later as an opaque Ractor::IsolationError. Mirrors
|
|
62
|
+
# Monk::Live.configure's own check of its registry argument.
|
|
65
63
|
unless Ractor.shareable?(registry)
|
|
66
64
|
raise ArgumentError,
|
|
67
65
|
"Monk::WebSocket::PgFanout.new needs a Ractor-shareable registry, got #{registry.class}"
|
|
@@ -79,20 +77,43 @@ module Monk
|
|
|
79
77
|
@pg_opts = Ractor.make_shareable(pg_opts.dup)
|
|
80
78
|
# Frozen explicitly, not just a String literal: read from every
|
|
81
79
|
# calling Ractor's own #broadcast and from the subscriber Ractor
|
|
82
|
-
#
|
|
80
|
+
# #listen! starts, both of which raise Ractor::IsolationError on an
|
|
83
81
|
# unfrozen value -- same reason RedisFanout freezes its own
|
|
84
82
|
# @origin.
|
|
85
83
|
@origin = SecureRandom.uuid.freeze
|
|
86
84
|
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Starts relaying other processes' broadcasts into this process's
|
|
89
|
+
# registry: a subscriber Ractor with its own LISTEN connection. Only
|
|
90
|
+
# the process that holds sockets calls this (bin/websocket_server, at
|
|
91
|
+
# boot, from the main Ractor) -- a publish-only process (bin/server,
|
|
92
|
+
# bin/jobs) never does, so it holds no LISTEN connection at all.
|
|
93
|
+
# Returns once the LISTEN is in effect, so nothing notified after it
|
|
94
|
+
# can be missed; raises ListenError if Postgres can't be reached.
|
|
95
|
+
# Calling it again is a no-op.
|
|
96
|
+
def listen!
|
|
97
|
+
return self if Listeners.listening?(@origin)
|
|
98
|
+
|
|
99
|
+
ready = Ractor::Port.new
|
|
87
100
|
# Ractor.new's block args must be shareable: registry is
|
|
88
101
|
# (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
|
-
|
|
102
|
+
# shareable), @pg_opts and @origin are already made so, a Port
|
|
103
|
+
# always is. The PG::Connection itself is never passed in --
|
|
104
|
+
# opened fresh inside the block, mirroring RedisFanout's
|
|
105
|
+
# subscriber and Monk::Persistence::Pg's per-Ractor connection
|
|
106
|
+
# pattern.
|
|
107
|
+
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
|
+
|
|
96
117
|
loop do
|
|
97
118
|
conn.wait_for_notify do |_channel, _pid, raw|
|
|
98
119
|
sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
|
|
@@ -103,10 +124,24 @@ module Monk
|
|
|
103
124
|
end
|
|
104
125
|
end
|
|
105
126
|
|
|
106
|
-
|
|
127
|
+
result = ready.receive
|
|
128
|
+
raise ListenError, "Monk::WebSocket::PgFanout#listen! couldn't LISTEN: #{result}" unless result == :ok
|
|
129
|
+
|
|
130
|
+
Listeners.add(@origin)
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def register(key, port)
|
|
135
|
+
unless Listeners.listening?(@origin)
|
|
136
|
+
raise NotListeningError,
|
|
137
|
+
"Monk::WebSocket::PgFanout#register before #listen!: this socket would never get " \
|
|
138
|
+
"other processes' broadcasts -- call listen! once at boot in the process that holds " \
|
|
139
|
+
"sockets (bin/websocket_server: Monk::Live.listen!)"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@registry.register(key, port)
|
|
107
143
|
end
|
|
108
144
|
|
|
109
|
-
def register(key, port) = @registry.register(key, port)
|
|
110
145
|
def unregister(key, port) = @registry.unregister(key, port)
|
|
111
146
|
def count(key) = @registry.count(key)
|
|
112
147
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "redis"
|
|
2
2
|
require "securerandom"
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "listeners"
|
|
3
5
|
|
|
4
6
|
module Monk
|
|
5
7
|
module WebSocket
|
|
@@ -9,7 +11,7 @@ module Monk
|
|
|
9
11
|
# LISTEN/NOTIFY per docs/design/websocket.md Open Question 3).
|
|
10
12
|
#
|
|
11
13
|
# Wraps a Registry with the same public interface (#register,
|
|
12
|
-
# #unregister, #count, #broadcast), so an app swaps which object it
|
|
14
|
+
# #unregister, #count, #broadcast, #listen!), so an app swaps which object it
|
|
13
15
|
# holds and nothing else changes. #broadcast still delivers to this
|
|
14
16
|
# process's local Registry directly -- same latency and reliability as
|
|
15
17
|
# a plain Registry if Redis is briefly unavailable -- and additionally
|
|
@@ -41,13 +43,43 @@ module Monk
|
|
|
41
43
|
@redis_url = redis_url.dup.freeze
|
|
42
44
|
@origin = SecureRandom.uuid.freeze
|
|
43
45
|
|
|
46
|
+
# No live connection lives on this object -- #listen! runs its
|
|
47
|
+
# subscriber in a Ractor of its own, and #publisher below opens one
|
|
48
|
+
# client per Ractor, lazily -- so there's nothing left unshareable
|
|
49
|
+
# and this instance can freeze itself the same way Registry does.
|
|
50
|
+
# That matters for real use: an app holds this behind a module
|
|
51
|
+
# constant (e.g. ChatServer::REGISTRY, mirroring Registry's own
|
|
52
|
+
# existing usage) read from every connection's own Ractor, which
|
|
53
|
+
# raises Ractor::IsolationError unless the constant's value is
|
|
54
|
+
# shareable.
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Starts relaying other processes' broadcasts into this process's
|
|
59
|
+
# registry: a subscriber Ractor with its own Redis connection. Only
|
|
60
|
+
# the process that holds sockets calls this (bin/websocket_server, at
|
|
61
|
+
# boot, from the main Ractor) -- a publish-only process (bin/server,
|
|
62
|
+
# bin/jobs) never does, so it holds no subscriber connection at all.
|
|
63
|
+
# Returns once the psubscribe is in effect, so nothing broadcast
|
|
64
|
+
# after it can be missed; raises ListenError if Redis can't be
|
|
65
|
+
# reached. Calling it again is a no-op.
|
|
66
|
+
def listen!
|
|
67
|
+
return self if Listeners.listening?(@origin)
|
|
68
|
+
|
|
69
|
+
ready = Ractor::Port.new
|
|
44
70
|
# Ractor.new's block args must be shareable: registry is (Registry
|
|
45
71
|
# freezes itself around a Ractor, which is inherently shareable),
|
|
46
|
-
#
|
|
47
|
-
# itself is built inside the block, never passed in --
|
|
48
|
-
# PG::Connection's per-Ractor pattern (persistence/pg.rb).
|
|
49
|
-
|
|
72
|
+
# the URL and origin are frozen Strings, a Port always is. The
|
|
73
|
+
# Redis client itself is built inside the block, never passed in --
|
|
74
|
+
# mirrors PG::Connection's per-Ractor pattern (persistence/pg.rb).
|
|
75
|
+
Ractor.new(@registry, @redis_url, @origin, ready) do |registry, url, origin, ready|
|
|
76
|
+
subscribed = false
|
|
50
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
|
+
|
|
51
83
|
on.pmessage do |_pattern, channel, envelope|
|
|
52
84
|
sender, payload = envelope.split("\0", 2)
|
|
53
85
|
next if sender == origin
|
|
@@ -66,19 +98,30 @@ module Monk
|
|
|
66
98
|
registry.broadcast(key, payload)
|
|
67
99
|
end
|
|
68
100
|
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
raise if subscribed
|
|
103
|
+
|
|
104
|
+
ready.send("#{e.class}: #{e.message}")
|
|
69
105
|
end
|
|
70
106
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
result = ready.receive
|
|
108
|
+
raise ListenError, "Monk::WebSocket::RedisFanout#listen! couldn't subscribe: #{result}" unless result == :ok
|
|
109
|
+
|
|
110
|
+
Listeners.add(@origin)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def register(key, port)
|
|
115
|
+
unless Listeners.listening?(@origin)
|
|
116
|
+
raise NotListeningError,
|
|
117
|
+
"Monk::WebSocket::RedisFanout#register before #listen!: this socket would never get " \
|
|
118
|
+
"other processes' broadcasts -- call listen! once at boot in the process that holds " \
|
|
119
|
+
"sockets (bin/websocket_server: Monk::Live.listen!)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@registry.register(key, port)
|
|
79
123
|
end
|
|
80
124
|
|
|
81
|
-
def register(key, port) = @registry.register(key, port)
|
|
82
125
|
def unregister(key, port) = @registry.unregister(key, port)
|
|
83
126
|
def count(key) = @registry.count(key)
|
|
84
127
|
|
|
@@ -67,6 +67,12 @@ module Monk
|
|
|
67
67
|
ask(:count, key, nil)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Nothing to listen to: every broadcast reaching this registry comes
|
|
71
|
+
# from this process. Here so a WS process calls #listen! the same
|
|
72
|
+
# way whether it holds a Registry or a RedisFanout/PgFanout around
|
|
73
|
+
# one.
|
|
74
|
+
def listen! = self
|
|
75
|
+
|
|
70
76
|
private
|
|
71
77
|
|
|
72
78
|
def ask(op, key, arg)
|