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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +2 -2
  4. data/lib/monk/base.rb +20 -0
  5. data/lib/monk/jobs/adapters/pg.rb +3 -1
  6. data/lib/monk/live/client/monk_live.js +2 -2
  7. data/lib/monk/live/client/protocol.js +5 -0
  8. data/lib/monk/live/policy.rb +39 -4
  9. data/lib/monk/live/session.rb +7 -0
  10. data/lib/monk/live.rb +15 -3
  11. data/lib/monk/persistence/errors.rb +32 -0
  12. data/lib/monk/persistence/pg.rb +92 -1
  13. data/lib/monk/persistence/pool.rb +513 -0
  14. data/lib/monk/persistence.rb +196 -7
  15. data/lib/monk/scaffold.rb +155 -78
  16. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  17. data/lib/monk/templates/auth/config/auth.rb +13 -20
  18. data/lib/monk/templates/base/app/app.rb +9 -0
  19. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  20. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  21. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  22. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  23. data/lib/monk/templates/base/app/models/.keep +0 -0
  24. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  25. data/lib/monk/templates/base/bin/websocket_server +17 -2
  26. data/lib/monk/templates/base/config/load.rb +18 -0
  27. data/lib/monk/templates/base/config.ru +3 -12
  28. data/lib/monk/templates/base/public/js/app.js +1 -1
  29. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  30. data/lib/monk/templates/jobs/bin/jobs +5 -13
  31. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  32. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  33. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  34. data/lib/monk/templates/live/bin/websocket_server +23 -3
  35. data/lib/monk/templates/live/config/live.rb +1 -1
  36. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  37. data/lib/monk/templates/mail/config/mail.rb +2 -2
  38. data/lib/monk/templates/postgres/bin/console +4 -2
  39. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  40. data/lib/monk/version.rb +1 -1
  41. data/lib/monk/websocket/connection.rb +25 -6
  42. data/lib/monk/websocket/errors.rb +11 -0
  43. data/lib/monk/websocket/listeners.rb +47 -0
  44. data/lib/monk/websocket/pg_fanout.rb +189 -31
  45. data/lib/monk/websocket/redis_fanout.rb +125 -33
  46. data/lib/monk/websocket/registry.rb +30 -0
  47. data/lib/monk/websocket/server.rb +128 -29
  48. data/lib/monk/websocket.rb +2 -0
  49. data/lib/monk.rb +10 -0
  50. metadata +21 -9
  51. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  52. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  53. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  54. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  55. /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 synchronous send, here in the job.
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
- require_relative "../config/settings"
4
-
5
- # Loaded when this app has them, so jobs can send mail or use Monk::Auth --
6
- # a missing file is a harmless no-op, the same way bin/websocket_server
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/, loaded below so the web process can enqueue it and bin/jobs can
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>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,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 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
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(port: port, authenticate: authenticate, allowed_origins: allowed_origins)
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.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
@@ -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__)
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.18.0".freeze
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
- # close the socket outright -- no wait for the client's own close
89
- # frame in reply.
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.close
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 { write(@port.receive) }
158
- rescue Ractor::ClosedError
159
- # #unsubscribe! closed the port out from under a pending #receive.
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
- 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
+ # 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
- @pg_opts = Ractor.make_shareable(pg_opts.dup)
181
+ raise ArgumentError, "Monk::WebSocket::PgFanout.new needs pg_opts: or db_pool:, not both" if pg_opts && db_pool
182
+ raise ArgumentError, "Monk::WebSocket::PgFanout.new needs pg_opts: or db_pool:" unless pg_opts || db_pool
183
+
184
+ @pg_opts = Ractor.make_shareable((pg_opts || pooled_pg_opts(db_pool)).dup)
185
+ @db_pool = db_pool
186
+ @db = db_pool && Monk::Persistence::Pg.pool_config(db_pool).db
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
- # below, both of which raise Ractor::IsolationError on an
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 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}")
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
- freeze
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] ||= PG.connect(**@pg_opts)
319
+ conn = Ractor.current[:monk_pg_fanout_publisher] ||= connect
320
+ Monk::Persistence::Pg.revive(conn) unless Monk::Persistence::Pg.alive?(conn)
321
+ conn
322
+ end
323
+
324
+ # Same connect_timeout default as Monk::Persistence::Pg's own
325
+ # connections: a reset runs inside a request's Live.patch, and
326
+ # mustn't wait indefinitely for a host that doesn't answer.
327
+ def connect
328
+ PG.connect(connect_timeout: Monk::Persistence::Pg::DEFAULT_CONNECT_TIMEOUT, **@pg_opts)
171
329
  end
172
330
  end
173
331
  end