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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +1 -1
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +5 -3
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/scaffold.rb +3 -1
- data/lib/monk/templates/auth/config/auth.rb +6 -0
- data/lib/monk/templates/base/bin/websocket_server +11 -2
- data/lib/monk/templates/live/bin/websocket_server +9 -1
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +54 -15
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +3 -1
data/lib/monk/persistence.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
8
|
+
VERSION = "0.20.0".freeze
|
|
9
9
|
end
|
|
@@ -85,11 +85,22 @@ module Monk
|
|
|
85
85
|
|
|
86
86
|
# The server-initiated close path (step 14): send a close frame
|
|
87
87
|
# carrying the 2-byte status code + reason RFC 6455 expects, then
|
|
88
|
-
#
|
|
89
|
-
#
|
|
88
|
+
# end the connection -- no wait for the client's own close frame in
|
|
89
|
+
# reply.
|
|
90
|
+
#
|
|
91
|
+
# Shut down, not closed: this runs from other threads too (a relay
|
|
92
|
+
# on Registry#close_all, the reverify thread) while the handler's
|
|
93
|
+
# thread is blocked reading. IO#close would wait for that reader,
|
|
94
|
+
# and the reader's cleanup kills the closing thread mid-close,
|
|
95
|
+
# leaving the descriptor open: the client never sees the close.
|
|
96
|
+
# shutdown tells the client at once and ends the blocked read (it
|
|
97
|
+
# returns nil, as on any disconnect); Server.serve's ensure closes
|
|
98
|
+
# the socket.
|
|
90
99
|
def close(code: 1000, reason: "")
|
|
91
100
|
write_frame([code].pack("n") + reason.to_s, 0x8)
|
|
92
|
-
@socket.
|
|
101
|
+
@socket.shutdown(:RDWR)
|
|
102
|
+
rescue Errno::ENOTCONN
|
|
103
|
+
# The client went away first: nothing left to shut down.
|
|
93
104
|
end
|
|
94
105
|
|
|
95
106
|
# Registers this connection's own Ractor::Port -- the "connection
|
|
@@ -154,9 +165,17 @@ module Monk
|
|
|
154
165
|
private
|
|
155
166
|
|
|
156
167
|
def relay_broadcasts
|
|
157
|
-
loop
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
loop do
|
|
169
|
+
message = @port.receive
|
|
170
|
+
next write(message) unless message.is_a?(CloseRequest)
|
|
171
|
+
|
|
172
|
+
# Registry#close_all: the read loop then sees the socket closed
|
|
173
|
+
# and Server.serve's cleanup runs as on any other close.
|
|
174
|
+
return close(code: message.code, reason: message.reason)
|
|
175
|
+
end
|
|
176
|
+
rescue Ractor::ClosedError, IOError, SystemCallError
|
|
177
|
+
# #unsubscribe! closed the port out from under a pending #receive,
|
|
178
|
+
# or the socket went away first.
|
|
160
179
|
end
|
|
161
180
|
|
|
162
181
|
def write_frame(payload, opcode)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] ||=
|
|
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
|