monkrb 0.17.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -0
  3. data/README.md +6 -5
  4. data/exe/monk +45 -7
  5. data/lib/monk/jobs/adapters/pg.rb +272 -0
  6. data/lib/monk/jobs/args.rb +43 -0
  7. data/lib/monk/jobs/claim.rb +7 -0
  8. data/lib/monk/jobs/errors.rb +29 -0
  9. data/lib/monk/jobs/job.rb +117 -0
  10. data/lib/monk/jobs/runtime.rb +239 -0
  11. data/lib/monk/jobs/worker.rb +94 -0
  12. data/lib/monk/jobs.rb +229 -0
  13. data/lib/monk/live/client/monk_live.js +2 -2
  14. data/lib/monk/live/client/protocol.js +5 -0
  15. data/lib/monk/live.rb +10 -0
  16. data/lib/monk/mail/errors.rb +7 -0
  17. data/lib/monk/mail/later.rb +53 -0
  18. data/lib/monk/persistence/pg.rb +21 -1
  19. data/lib/monk/scaffold.rb +369 -70
  20. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  21. data/lib/monk/templates/auth/config/auth.rb +7 -20
  22. data/lib/monk/templates/base/app/app.rb +9 -0
  23. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  24. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  25. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  26. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  27. data/lib/monk/templates/base/app/models/.keep +0 -0
  28. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  29. data/lib/monk/templates/base/bin/websocket_server +6 -0
  30. data/lib/monk/templates/base/config/load.rb +18 -0
  31. data/lib/monk/templates/base/config.ru +3 -12
  32. data/lib/monk/templates/base/public/js/app.js +1 -1
  33. data/lib/monk/templates/jobs/app/jobs/hello_job.rb +9 -0
  34. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +28 -0
  35. data/lib/monk/templates/jobs/bin/jobs +18 -0
  36. data/lib/monk/templates/jobs/config/jobs.rb +15 -0
  37. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql +3 -0
  38. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql +48 -0
  39. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  40. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  41. data/lib/monk/templates/live/bin/websocket_server +14 -2
  42. data/lib/monk/templates/live/config/live.rb +1 -1
  43. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  44. data/lib/monk/templates/mail/config/mail.rb +2 -2
  45. data/lib/monk/templates/postgres/Dockerfile +3 -1
  46. data/lib/monk/templates/postgres/bin/console +4 -2
  47. data/lib/monk/version.rb +1 -1
  48. data/lib/monk/websocket/errors.rb +11 -0
  49. data/lib/monk/websocket/listeners.rb +23 -0
  50. data/lib/monk/websocket/pg_fanout.rb +55 -20
  51. data/lib/monk/websocket/redis_fanout.rb +57 -14
  52. data/lib/monk/websocket/registry.rb +6 -0
  53. data/lib/monk/websocket/server.rb +77 -17
  54. metadata +32 -7
  55. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  56. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  57. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  58. /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, through Monk::Mail (config/mail.rb, which
11
- # config.ru requires) -- a module constant, not a lambda inline in
12
- # Auth.configure below, since self at this file's top level isn't
13
- # Ractor-shareable (same constraint as Monk::Live.authorize blocks). The
14
- # HTML part is views/mail/magic_link.erb. Swap the body for SMS or any
15
- # other channel; see docs/guides/auth.md.
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::DELIVER,
24
+ deliver: AppMailer::MAGIC_LINK,
38
25
  )
@@ -0,0 +1,9 @@
1
+ class App < Monk::Base
2
+ views "app/views"
3
+ layout "layouts/app"
4
+ assets "public"
5
+
6
+ get("/") { @title = "App"; render "index" }
7
+ get("/hello") { "hello from monk" }
8
+ get("/api/hello") { json(message: "hello from monk") }
9
+ end
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/settings"
1
+ require_relative "config/load" # the configs and app/ (config/load.rb)
2
+ require_relative "app/app"
2
3
 
3
- class App < Monk::Base
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");
@@ -0,0 +1,9 @@
1
+ # The demo job: POST /jobs/hello enqueues it, bin/jobs runs it, and its
2
+ # line lands in log/<env>.log. A job's args must be plain JSON values (an
3
+ # id, not a record), and a job may run more than once, so make it safe to
4
+ # repeat. Replace this with the app's own jobs.
5
+ class HelloJob < Monk::Job
6
+ def self.perform(name)
7
+ Monk::Log.info("Hello, #{name}, from a background job")
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ # Sends a magic link from bin/jobs instead of the login request. The token
2
+ # is created here, right before it's sent, so the raw token is never
3
+ # stored anywhere -- not even in the job queue, which a plain
4
+ # Monk::Mail.deliver_later of the link would do (Monk's
5
+ # docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
6
+ # Enqueue it from your login route, after your own per-email rate limit
7
+ # and with a redirect_to already checked against redirect_allowlist:
8
+ #
9
+ # post("/auth/request") do
10
+ # SendLoginLink.enqueue(params[:email])
11
+ # json(sent: true)
12
+ # end
13
+ class SendLoginLink < Monk::Job
14
+ queue "mailers"
15
+ # About 50 seconds of retries: a login email minutes late, after the
16
+ # user has likely asked for another, is worse than none.
17
+ max_attempts 3
18
+ never_retry Monk::InvalidRedirectError
19
+
20
+ def self.perform(email, redirect_to = nil)
21
+ token = Monk::Auth.request_login(email, redirect_to: redirect_to)
22
+ # Your callback route; config/settings.rb's public_url is the origin.
23
+ link = "#{Monk::Settings[:public_url]}/auth/callback/#{token}"
24
+ # Calls config/auth.rb's deliver: (AppMailer::MAGIC_LINK) -- a
25
+ # synchronous send, here in the job.
26
+ Monk::Auth.deliver_link(email: email, link: link, token: token)
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
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"
7
+ require "monk/jobs/runtime"
8
+
9
+ # The app's templates, so a job can render one (a mail's HTML part, say):
10
+ # there's no App class here to point Monk::Views at them.
11
+ Monk::Views.root = File.expand_path("../app/views", __dir__)
12
+
13
+ workers = Integer(ENV.fetch("JOBS_WORKERS", "5"))
14
+ queues = ENV.fetch("JOBS_QUEUES", "default").split(",").map(&:strip)
15
+ puts "Monk::Jobs: #{workers} worker(s) on #{queues.join(", ")} -- TERM or Ctrl-C finishes the jobs in hand and stops"
16
+ $stdout.flush
17
+
18
+ Monk::Jobs::Runtime.new(workers: workers, queues: queues).run
@@ -0,0 +1,15 @@
1
+ require "monk"
2
+ require "monk/jobs"
3
+ require_relative "persistence"
4
+
5
+ # Background jobs (Monk::Jobs): enqueue from any route, e.g.
6
+ # HelloJob.enqueue("world"); bin/jobs runs them. Every job is a class under
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
+ #
10
+ # The queue lives in the app's own database, so a job can be enqueued
11
+ # inside the app's own transaction: HelloJob.enqueue("world", conn: conn).
12
+ # Once the app has long-running transactions or heavy load, give the queue
13
+ # a database of its own -- register it in config/persistence.rb and pass
14
+ # its name here (Monk's docs/guides/jobs.md, "Keeping the queue healthy").
15
+ Monk::Jobs.configure(db_name: :primary)
@@ -0,0 +1,3 @@
1
+ DROP TABLE monk_processes;
2
+ DROP TABLE monk_job_payloads;
3
+ DROP TABLE monk_jobs;
@@ -0,0 +1,48 @@
1
+ -- Monk::Jobs: a narrow state table, a payload table, one row per job
2
+ -- process. Why this shape: docs/adr/0013-jobs-narrow-state-table-plus-payloads.md.
3
+
4
+ -- Scheduling and state only, about 70 bytes a row: claiming a job
5
+ -- rewrites this row, never the payload.
6
+ CREATE TABLE monk_jobs (
7
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
8
+ queue TEXT NOT NULL DEFAULT 'default',
9
+ priority SMALLINT NOT NULL DEFAULT 0, -- lower runs sooner
10
+ state TEXT NOT NULL CHECK (state IN ('available', 'scheduled', 'running', 'failed')),
11
+ run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12
+ attempts SMALLINT NOT NULL DEFAULT 0,
13
+ max_attempts SMALLINT NOT NULL DEFAULT 5,
14
+ locked_by BIGINT, -- monk_processes.id while running
15
+ locked_at TIMESTAMPTZ
16
+ );
17
+
18
+ -- One partial index per lookup, each holding only the rows it wants:
19
+ -- workers claim from the first, the stager promotes due jobs from the
20
+ -- second, pruning a dead process finds its jobs through the third.
21
+ CREATE INDEX monk_jobs_available ON monk_jobs (queue, priority, run_at, id) WHERE state = 'available';
22
+ CREATE INDEX monk_jobs_scheduled ON monk_jobs (run_at) WHERE state = 'scheduled';
23
+ CREATE INDEX monk_jobs_running ON monk_jobs (locked_by) WHERE state = 'running';
24
+
25
+ -- Every job's row is updated and deleted within seconds, so vacuum it
26
+ -- sooner than Postgres's defaults would (1% of rows dead rather than 20%,
27
+ -- and without cost-based throttling).
28
+ ALTER TABLE monk_jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 0);
29
+
30
+ -- Written once at enqueue, deleted with its job, updated only to record
31
+ -- an error.
32
+ CREATE TABLE monk_job_payloads (
33
+ job_id BIGINT PRIMARY KEY REFERENCES monk_jobs (id) ON DELETE CASCADE,
34
+ job_class TEXT NOT NULL,
35
+ args JSONB NOT NULL,
36
+ last_error TEXT,
37
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
38
+ );
39
+
40
+ -- One row per bin/jobs process: a heartbeat that stops means its
41
+ -- running jobs go back to available.
42
+ CREATE TABLE monk_processes (
43
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
44
+ hostname TEXT NOT NULL,
45
+ pid INTEGER NOT NULL,
46
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
47
+ last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now()
48
+ );
@@ -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>config.ru</code>), and the rules and Redis
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 config.ru's process publishes over
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
- authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
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.ru (this app
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.ru (this app
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 config/auth.rb's AppMailer
6
- # delivers. See docs/guides/mail.md. Required from config.ru (not from
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
@@ -25,6 +25,8 @@ COPY . .
25
25
  # platform that assigns its own). bin/websocket_server (WS_PORT default
26
26
  # 9293) is a separate process/deploy unit -- run this same image with
27
27
  # `bin/websocket_server` as the command instead of publishing another
28
- # port from this Dockerfile -- see docs/guides/deploying.md.
28
+ # port from this Dockerfile -- see docs/guides/deploying.md. With --jobs,
29
+ # bin/jobs is another one, the same way: this image, `bin/jobs` as the
30
+ # command, no port at all.
29
31
  EXPOSE 9292
30
32
  CMD ["bin/server", "--bind", "0.0.0.0"]
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- require_relative "../config/settings"
4
- require_relative "../config/persistence"
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.17.0".freeze
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, before anything below opens a
57
- # real Postgres connection -- RedisFanout has no equivalent check
58
- # (freezing self never raises on its own, even when an ivar isn't
59
- # itself shareable), so a bad registry there only surfaces later
60
- # as an opaque Ractor::IsolationError. Checking here first, rather
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
- # below, both of which raise Ractor::IsolationError on an
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 above. The
90
- # PG::Connection itself is never passed in -- opened fresh inside
91
- # the block, mirroring RedisFanout's subscriber and
92
- # Monk::Persistence::Pg's per-Ractor connection pattern.
93
- @subscriber = Ractor.new(registry, @pg_opts, @origin) do |registry, pg_opts, own_origin|
94
- conn = PG.connect(**pg_opts)
95
- conn.exec("LISTEN #{Monk::WebSocket::PgFanout::CHANNEL}")
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
- freeze
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