monkrb 0.19.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.
@@ -1,4 +1,5 @@
1
1
  require_relative "freeze_hooks"
2
+ require_relative "persistence/pool"
2
3
 
3
4
  module Monk
4
5
  # Backend-agnostic persistence. Concrete backends (e.g.
@@ -8,6 +9,16 @@ module Monk
8
9
  # can seal every backend actually in use without needing to know their
9
10
  # names.
10
11
  module Persistence
12
+ # Closes every connection this Ractor opened, in every backend. A
13
+ # Ractor that ends leaves its connections to the garbage collector,
14
+ # which may not run for a long time; one that opened any calls this
15
+ # as it finishes (Monk::WebSocket::Server.serve, for a socket's).
16
+ def self.disconnect_all
17
+ # A copy: each backend's disconnect_all removes its own key.
18
+ backends = Ractor.current[:monk_persistence]&.keys || []
19
+ backends.each(&:disconnect_all)
20
+ end
21
+
11
22
  # Shared by every backend module: per-Ractor connection lifecycle, a
12
23
  # registry of named configs, and boot-time shareability sealing. A
13
24
  # backend `extend`s this and implements #connect(**opts) /
@@ -27,6 +38,14 @@ module Monk
27
38
  configs[name] = opts
28
39
  end
29
40
 
41
+ # The options a database was registered with, for something that
42
+ # opens its own connection to it (PgFanout's LISTEN).
43
+ def connection_options(name)
44
+ configs.fetch(name) do
45
+ raise Monk::UnknownPersistenceError, "no database registered as #{name.inspect}"
46
+ end
47
+ end
48
+
30
49
  # Registered connection names (e.g. :primary) -- Monk.boot's log
31
50
  # line reads this to report which backends an app actually uses.
32
51
  def names
@@ -37,6 +56,76 @@ module Monk
37
56
  entry(name).conn
38
57
  end
39
58
 
59
+ # With options, declares a named pool (docs/history/plan-pg-pool.md):
60
+ # `size` worker Ractors, each with its own connection to the
61
+ # registered database `db`, that run calls sent from any other
62
+ # Ractor. Nothing connects until #start_pools! runs, in the process
63
+ # that uses it. Every process loads the declarations
64
+ # (config/persistence.rb), next to #register.
65
+ #
66
+ # Without options, returns the started pool, from any Ractor.
67
+ # Raises if it was never declared, or declared but not started in
68
+ # this process.
69
+ def pool(name, **options)
70
+ return declare_pool(name, **options) unless options.empty?
71
+
72
+ handle = @running && @running[name]
73
+ return handle if handle
74
+
75
+ pool_config(name)
76
+ raise Monk::PoolNotStartedError,
77
+ "pool #{name.inspect} is declared but not started in this process -- call " \
78
+ "#{self}.start_pools!(#{name.inspect}) at boot in the process that uses it"
79
+ end
80
+
81
+ # Starts declared pools in this process: each pool's dispatcher and
82
+ # `size` workers, each worker with its own connection. Returns once
83
+ # every worker has connected; raises Monk::PoolStartError if any
84
+ # can't, and starts nothing for that pool. Main Ractor only, at boot,
85
+ # after every register and pool declaration: it freezes the
86
+ # registry, since the workers read it. A pool already started is
87
+ # left alone.
88
+ def start_pools!(*names)
89
+ unless Ractor.current == Ractor.main
90
+ raise ArgumentError, "#{self}.start_pools! runs in the main Ractor, at boot"
91
+ end
92
+
93
+ freeze_registry!
94
+ names.each { |name| start_pool(name) unless @running&.key?(name) }
95
+ self
96
+ end
97
+
98
+ # Hook: makes a copy of an exception fit to leave a pool's worker for
99
+ # another Ractor, by dropping what can't be copied. A backend whose
100
+ # errors carry nothing like that keeps this default.
101
+ def make_portable(_error) = nil
102
+
103
+ # This Ractor's own connections to this backend, closed and
104
+ # forgotten. A pool worker runs it as it stops; the next checkout in
105
+ # this Ractor reconnects.
106
+ def disconnect_all
107
+ Ractor.current[:monk_persistence]&.delete(self)&.each_value do |e|
108
+ disconnect(e.conn)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ end
113
+
114
+ # The pools started in this process, name => size (Monk.boot's line).
115
+ def running_pools
116
+ (@running || {}).transform_values { |handle| handle.config.size }
117
+ end
118
+
119
+ # A declared pool's options (a frozen Pool::Config).
120
+ def pool_config(name)
121
+ pools.fetch(name) do
122
+ raise Monk::UnknownPoolError,
123
+ "pool #{name.inspect} was never declared -- declare it once at boot with " \
124
+ "#{self}.pool(#{name.inspect}, size: #{Pool::DEFAULTS[:size]}); " \
125
+ "pool(#{name.inspect}) with no options only looks one up"
126
+ end
127
+ end
128
+
40
129
  def checkout(name, timeout: DEFAULT_CHECKOUT_TIMEOUT)
41
130
  e = entry(name)
42
131
  token = e.slot.pop(timeout: timeout)
@@ -46,9 +135,18 @@ module Monk
46
135
  "(checkout held longer than #{timeout}s)"
47
136
  end
48
137
 
138
+ # Checked here, before the block, and never by re-running it: Monk
139
+ # can't tell whether a failed block's writes reached the server,
140
+ # so a drop in the middle of a block fails that block, and the
141
+ # next checkout finds the connection dead and revives it. A
142
+ # revive that can't reach the server raises from here.
143
+ revive(e.conn) unless alive?(e.conn)
49
144
  yield e.conn
50
145
  ensure
51
- e.slot << true if token
146
+ if token
147
+ release(e.conn)
148
+ e.slot << true
149
+ end
52
150
  end
53
151
 
54
152
  # Called from Base#freeze! (Seam B), via Monk.freeze_hooks.
@@ -61,17 +159,16 @@ module Monk
61
159
  # the value (not the module) fixes it, the same way it did there.
62
160
  def freeze_registry!
63
161
  @configs = Ractor.make_shareable(configs)
162
+ @pools = Ractor.make_shareable(pools)
64
163
  end
65
164
 
66
165
  # Test-only: drops all registered configs and this Ractor's cached
67
166
  # connections. Not part of the app-facing API.
68
167
  def reset!
69
- Ractor.current[:monk_persistence]&.each_value do |e|
70
- disconnect(e.conn)
71
- rescue StandardError
72
- end
73
- Ractor.current[:monk_persistence] = {}
168
+ stop_pools
169
+ disconnect_all
74
170
  @configs = {}
171
+ @pools = {}
75
172
  end
76
173
 
77
174
  private
@@ -80,8 +177,87 @@ module Monk
80
177
  @configs ||= {}
81
178
  end
82
179
 
180
+ def pools
181
+ @pools ||= {}
182
+ end
183
+
184
+ # The started pools' dispatchers and inboxes, which only this (the
185
+ # main) Ractor uses: to stop them. Kept apart from @running, the
186
+ # frozen handles every Ractor reads.
187
+ def pool_ractors
188
+ @pool_ractors ||= {}
189
+ end
190
+
191
+ def start_pool(name)
192
+ ready = Ractor::Port.new
193
+ dispatcher = Ractor.new(self, pool_config(name), ready, name: "monk-pool-#{name}") do |backend, config, r|
194
+ Monk::Persistence::Pool::Dispatcher.run(backend, config, r)
195
+ end
196
+ status, value = ready.receive
197
+ unless status == :ok
198
+ dispatcher.join
199
+ raise Monk::PoolStartError, "pool #{name.inspect} couldn't connect its workers: #{value}"
200
+ end
201
+
202
+ pool_ractors[name] = { dispatcher: dispatcher, inbox: value }
203
+ handle = Pool::Handle.new(config: pool_config(name), inbox: value)
204
+ @running = Ractor.make_shareable((@running || {}).merge(name => handle))
205
+ ensure
206
+ ready&.close
207
+ end
208
+
209
+ # Test-only path (reset!). The dispatcher stops its workers, each
210
+ # finishing the call in hand, and answers every caller still waiting.
211
+ def stop_pools
212
+ pool_ractors.each_key { |name| stop_pool(name) }
213
+ @running = nil
214
+ end
215
+
216
+ def stop_pool(name)
217
+ stopping = pool_ractors.delete(name)
218
+ return unless stopping
219
+
220
+ send_stop(stopping[:inbox], [:stop])
221
+ stopping[:dispatcher].join
222
+ end
223
+
224
+ def send_stop(port, message)
225
+ port.send(message)
226
+ rescue Ractor::ClosedError
227
+ nil # already ended
228
+ end
229
+
230
+ def declare_pool(name, **options)
231
+ unknown = options.keys - Pool::DEFAULTS.keys
232
+ raise ArgumentError, "unknown pool option(s) #{unknown.join(", ")} for #{name.inspect}" if unknown.any?
233
+ raise ArgumentError, "pool #{name.inspect} is already declared" if pools.key?(name)
234
+
235
+ config = Pool::Config.new(name: name, **Pool::DEFAULTS, **options)
236
+ validate_pool!(config)
237
+ pools[name] = Ractor.make_shareable(config)
238
+ end
239
+
240
+ def validate_pool!(config)
241
+ unless configs.key?(config.db)
242
+ raise Monk::UnknownPersistenceError,
243
+ "pool #{config.name.inspect} uses db: #{config.db.inspect}, but no database is registered as " \
244
+ "#{config.db.inspect} -- call #{self}.register(#{config.db.inspect}, ...) before declaring the pool"
245
+ end
246
+
247
+ { size: Integer, queue: Integer, timeout: Numeric }.each do |option, type|
248
+ value = config.public_send(option)
249
+ next if value.is_a?(type) && value.positive?
250
+
251
+ raise ArgumentError,
252
+ "pool #{config.name.inspect}: #{option}: must be a positive #{type}, got #{value.inspect}"
253
+ end
254
+ end
255
+
256
+ # This Ractor's connections, by backend, then by name: so
257
+ # Persistence.disconnect_all can find every backend this Ractor
258
+ # used, from any Ractor.
83
259
  def ractor_local
84
- Ractor.current[:monk_persistence] ||= {}
260
+ (Ractor.current[:monk_persistence] ||= {})[self] ||= {}
85
261
  end
86
262
 
87
263
  def entry(name)
@@ -108,6 +284,19 @@ module Monk
108
284
  def disconnect(conn)
109
285
  raise NotImplementedError, "#{self} must implement #disconnect(conn)"
110
286
  end
287
+
288
+ # Optional hooks around #checkout's block. A backend that can't tell
289
+ # a dead connection from a live one keeps these defaults.
290
+
291
+ # Whether conn can take a query. Cheap: runs before every checkout.
292
+ def alive?(_conn) = true
293
+
294
+ # Reconnects a connection #alive? said was dead; raises if it can't.
295
+ def revive(_conn) = nil
296
+
297
+ # Runs after every checkout's block, however it ended. Must not
298
+ # raise: it runs in an ensure, and would hide the block's exception.
299
+ def release(_conn) = nil
111
300
  end
112
301
  end
113
302
  end
data/lib/monk/scaffold.rb CHANGED
@@ -39,6 +39,9 @@ module Monk
39
39
  "bin/console" => "postgres/bin/console",
40
40
  "bin/setup_db" => "postgres/bin/setup_db",
41
41
  "bin/migrate" => "postgres/bin/migrate",
42
+ # Kept by git when empty, like app/'s role directories: a fresh clone
43
+ # still shows where migrations go. The migrator reads only *.sql.
44
+ "db/migrate/.keep" => "postgres/db/migrate/.keep",
42
45
  }.freeze
43
46
 
44
47
  # The pg gem's native extension needs libpq -- the base Dockerfile has
@@ -229,7 +232,6 @@ module Monk
229
232
 
230
233
  if @postgres
231
234
  POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
232
- FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
233
235
  append_gemfile_extra("postgres/Gemfile.extra")
234
236
  end
235
237
 
@@ -23,3 +23,9 @@ Monk::Auth.configure(
23
23
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
24
24
  deliver: AppMailer::MAGIC_LINK,
25
25
  )
26
+
27
+ # bin/websocket_server checks each socket's session through this pool, so
28
+ # it holds 4 connections however many pages are open, instead of one per
29
+ # open page. Declaring it opens nothing: only bin/websocket_server starts
30
+ # it. See docs/guides/persistence.md, "Pools", in the monk repo.
31
+ Monk::Persistence::Pg.pool(:auth, size: 4)
@@ -54,7 +54,14 @@ end
54
54
  # has nothing to listen to, and listen! does nothing.
55
55
  ChatServer::REGISTRY.listen!
56
56
 
57
- authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
57
+ # false, not nil, without --auth: Server.new refuses nil.
58
+ authenticate = !!(defined?(Monk::Auth) && Monk::Auth.config)
59
+ # Sessions are checked through config/auth.rb's :auth pool rather than in
60
+ # each socket's own Ractor, which would hold a Postgres connection per
61
+ # open page. This is the only process that uses the pool, so it starts it.
62
+ db_pool = (:auth if authenticate)
63
+ Monk::Persistence::Pg.start_pools!(db_pool) if db_pool
64
+
58
65
  port = ENV.fetch("WS_PORT", "9293").to_i
59
66
  # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
60
67
  # keeps this in sync with the app's actual origin instead of two vars
@@ -62,7 +69,9 @@ port = ENV.fetch("WS_PORT", "9293").to_i
62
69
  # more than one origin (comma-separated).
63
70
  allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
64
71
 
65
- server = Monk::WebSocket::Server.new(port: port, authenticate: authenticate, allowed_origins: allowed_origins)
72
+ server = Monk::WebSocket::Server.new(
73
+ port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
74
+ )
66
75
  puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
67
76
  "(authenticate: #{authenticate}, redis fan-out: #{ENV["REDIS_URL"] ? "on" : "off"})"
68
77
  server.run(&ChatServer::HANDLER)
@@ -30,6 +30,12 @@ Monk::Live.listen!
30
30
  # `anonymous: true`, like the demo's "hits". Without Monk::Auth every
31
31
  # socket is anonymous.
32
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
+
33
39
  port = ENV.fetch("WS_PORT", "9293").to_i
34
40
  # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
35
41
  # keeps this in sync with the app's actual origin instead of two vars
@@ -37,6 +43,8 @@ port = ENV.fetch("WS_PORT", "9293").to_i
37
43
  # more than one origin (comma-separated).
38
44
  allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
39
45
 
40
- 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
+ )
41
49
  puts "Monk::Live WebSocket server listening on ws://0.0.0.0:#{port} (authenticate: #{authenticate})"
42
50
  server.run(&Monk::Live::HANDLER)
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.19.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)
@@ -5,12 +5,36 @@ module Monk
5
5
  # module constant), so it can't record this on itself; a frozen list
6
6
  # in a module ivar is written from the main Ractor, where #listen! runs
7
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.
8
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
+
9
23
  @origins = [].freeze
10
24
 
11
25
  class << self
12
26
  def listening?(origin) = @origins.include?(origin)
13
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
+
14
38
  # Main Ractor only -- a module ivar write from another Ractor raises
15
39
  # Ractor::IsolationError, which is the right answer for a boot-time
16
40
  # call made from the wrong place.
@@ -1,5 +1,6 @@
1
1
  require "pg"
2
2
  require "securerandom"
3
+ require_relative "../persistence/pg"
3
4
  require_relative "errors"
4
5
  require_relative "listeners"
5
6
 
@@ -54,7 +55,110 @@ module Monk
54
55
  [origin, key, payload]
55
56
  end
56
57
 
57
- def initialize(registry, pg_opts:)
58
+ # The body of #listen!'s subscriber Ractor. A class method, like
59
+ # .decode_envelope, because the Ractor has no PgFanout instance to
60
+ # call into. Reports :ok, or why it couldn't LISTEN, on `ready`, then
61
+ # relays notifies for the rest of the process's life.
62
+ #
63
+ # A dropped LISTEN connection (a Postgres restart, a failover) is
64
+ # reconnected with backoff (plan-pg-reconnect.md). Notifies sent
65
+ # while it was down are lost, so once listening again every open
66
+ # socket is closed (Listeners::MISSED_CODE), and each client
67
+ # reconnects and resyncs; their jittered reconnect spreads the
68
+ # refetches.
69
+ def self.subscribe(registry, pg_opts, own_origin, ready)
70
+ begin
71
+ conn = listen_connection(pg_opts)
72
+ rescue StandardError => e
73
+ ready.send("#{e.class}: #{e.message}")
74
+ return
75
+ end
76
+ ready.send(:ok)
77
+
78
+ loop do
79
+ conn.wait_for_notify { |_channel, _pid, raw| relay(registry, own_origin, raw) }
80
+ rescue PG::ConnectionBad => e
81
+ log(:error, "lost the LISTEN connection (#{e.message.lines.first&.strip}); reconnecting")
82
+ conn = reconnect(pg_opts)
83
+ closed = registry.close_all(Listeners::MISSED_CODE, Listeners::MISSED_REASON)
84
+ log(:info, "listening again; closed #{closed} socket(s) so their pages resync")
85
+ end
86
+ end
87
+
88
+ # One notify into this process's registry. Anything that goes wrong
89
+ # here (a payload that isn't an envelope, a failing broadcast) drops
90
+ # this notify only: escaping, it would end the subscriber Ractor, and
91
+ # with it every later broadcast, silently.
92
+ def self.relay(registry, own_origin, raw)
93
+ sender, key, payload = decode_envelope(raw)
94
+ return if sender == own_origin
95
+
96
+ registry.broadcast(key.to_sym, payload)
97
+ rescue StandardError => e
98
+ log(:error, "dropped a notify: #{e.class}: #{e.message}")
99
+ end
100
+
101
+ def self.listen_connection(pg_opts)
102
+ conn = PG.connect(connect_timeout: Monk::Persistence::Pg::DEFAULT_CONNECT_TIMEOUT, **pg_opts)
103
+ conn.exec("LISTEN #{CHANNEL}")
104
+ conn
105
+ rescue StandardError
106
+ conn&.close
107
+ raise
108
+ end
109
+
110
+ def self.reconnect(pg_opts)
111
+ delay = Listeners::RETRY_INITIAL
112
+ begin
113
+ listen_connection(pg_opts)
114
+ rescue PG::Error => e
115
+ log(:warn, "can't LISTEN yet (#{e.class}: #{e.message.lines.first&.strip}); retrying in #{delay}s")
116
+ sleep delay
117
+ delay = Listeners.next_delay(delay)
118
+ retry
119
+ end
120
+ end
121
+
122
+ def self.log(level, message) = Listeners.log(name, level, message)
123
+ private_class_method :relay, :listen_connection, :reconnect, :log
124
+
125
+ # The body of a pooled publish (#broadcast with db_pool:), run in the
126
+ # pool's worker on its connection. That connection serves nothing
127
+ # but pool calls, each of which ends its transaction, so a NOTIFY
128
+ # here is never held back by an app's open transaction.
129
+ def self.notify(db, envelope)
130
+ Monk::Persistence::Pg.checkout(db) { |conn| conn.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope]) }
131
+ nil
132
+ end
133
+
134
+ # Closes the calling Ractor's publisher connection (#publisher), if
135
+ # it opened one; the next broadcast in this Ractor reconnects.
136
+ # Server.serve runs it as each socket ends: a Ractor that ends
137
+ # leaves its connection to the garbage collector, which may not run
138
+ # for a long time.
139
+ def self.disconnect_publisher
140
+ conn = Ractor.current[:monk_pg_fanout_publisher]
141
+ return unless conn
142
+
143
+ Ractor.current[:monk_pg_fanout_publisher] = nil
144
+ conn.finish
145
+ rescue PG::Error
146
+ nil # already closed
147
+ end
148
+
149
+ # pg_opts: connects the publisher (one connection per Ractor that
150
+ # publishes) and #listen!'s LISTEN connection. db_pool: (a
151
+ # Monk::Persistence::Pg pool of size 1, docs/history/plan-pg-pool.md)
152
+ # publishes through that pool instead, so a process holds one
153
+ # publishing connection however many Ractors publish, and LISTENs on
154
+ # the pool's database. Exactly one of the two.
155
+ #
156
+ # Through the pool a publish doesn't wait for Postgres (call_async):
157
+ # a failed pg_notify is logged, not raised, and the page catches up
158
+ # at its next resync, as it would after any missed patch. Size 1
159
+ # because only a single worker runs one sender's publishes in the
160
+ # order they were made, and patches to a page must arrive in order.
161
+ def initialize(registry, pg_opts: nil, db_pool: nil)
58
162
  # Checked upfront, on the argument -- RedisFanout has no equivalent
59
163
  # check (freezing self never raises on its own, even when an ivar
60
164
  # isn't itself shareable), so a bad registry there only surfaces
@@ -74,7 +178,12 @@ module Monk
74
178
  # fails on this Hash's contents, not on the registry argument it's
75
179
  # meant to guard, the same trap Server#initialize's
76
180
  # allowed_origins already avoids the same way.
77
- @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
78
187
  # Frozen explicitly, not just a String literal: read from every
79
188
  # calling Ractor's own #broadcast and from the subscriber Ractor
80
189
  # #listen! starts, both of which raise Ractor::IsolationError on an
@@ -105,23 +214,7 @@ module Monk
105
214
  # subscriber and Monk::Persistence::Pg's per-Ractor connection
106
215
  # pattern.
107
216
  Ractor.new(@registry, @pg_opts, @origin, ready) do |registry, pg_opts, own_origin, ready|
108
- 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
-
117
- loop do
118
- conn.wait_for_notify do |_channel, _pid, raw|
119
- sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
120
- next if sender == own_origin
121
-
122
- registry.broadcast(key.to_sym, payload)
123
- end
124
- end
217
+ Monk::WebSocket::PgFanout.subscribe(registry, pg_opts, own_origin, ready)
125
218
  end
126
219
 
127
220
  result = ready.receive
@@ -166,11 +259,25 @@ module Monk
166
259
  # parameter sidesteps any quoting/injection concern for its bytes
167
260
  # entirely, unlike Redis's PUBLISH, which never had that concern
168
261
  # in the first place.
262
+ return Monk::Persistence::Pg.pool(@db_pool).call_async(PgFanout, :notify, @db, envelope) if @db_pool
263
+
169
264
  publisher.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope])
170
265
  end
171
266
 
172
267
  private
173
268
 
269
+ # The pool's database's connection options, for #listen!.
270
+ def pooled_pg_opts(db_pool)
271
+ config = Monk::Persistence::Pg.pool_config(db_pool)
272
+ unless config.size == 1
273
+ raise ArgumentError,
274
+ "Monk::WebSocket::PgFanout's db_pool #{db_pool.inspect} has size #{config.size}; it must have size 1: " \
275
+ "patches to a page must arrive in the order they were published, and only one worker keeps " \
276
+ "a sender's publishes in order"
277
+ end
278
+ Monk::Persistence::Pg.connection_options(config.db)
279
+ end
280
+
174
281
  # Length-prefixes the origin and key (Netstring-style: "<bytesize>:"
175
282
  # then that many bytes) instead of RedisFanout's NUL-separated
176
283
  # "origin\0payload" -- discovered while building this that a NUL
@@ -201,8 +308,24 @@ module Monk
201
308
  # out for app queries: NOTIFY issued on a connection mid-transaction
202
309
  # wouldn't fire until that transaction commits, so this stays a
203
310
  # connection of its own, used for nothing but one-off notifies.
311
+ #
312
+ # Probed before each notify and reset if Postgres dropped it
313
+ # (Monk::Persistence::Pg.alive?, plan-pg-reconnect.md), the way
314
+ # checkout treats an app's connection. A failed pg_notify is never
315
+ # retried: it may have reached the server, and a patch delivered
316
+ # twice shows twice, while a missed one is recovered by the client's
317
+ # resync.
204
318
  def publisher
205
- Ractor.current[:monk_pg_fanout_publisher] ||= 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)
206
329
  end
207
330
  end
208
331
  end