monkrb 0.15.1 → 0.17.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 +61 -0
- data/README.md +6 -5
- data/exe/monk +40 -11
- data/lib/monk/auth.rb +3 -3
- data/lib/monk/errors.rb +3 -0
- data/lib/monk/mail/address.rb +41 -0
- data/lib/monk/mail/errors.rb +28 -0
- data/lib/monk/mail/message.rb +66 -0
- data/lib/monk/mail/mime.rb +124 -0
- data/lib/monk/mail/transports.rb +115 -0
- data/lib/monk/mail/url.rb +76 -0
- data/lib/monk/mail.rb +121 -0
- data/lib/monk/scaffold.rb +178 -22
- data/lib/monk/templates/auth/config/auth.rb +18 -8
- data/lib/monk/templates/auth/views/mail/magic_link.erb +3 -0
- data/lib/monk/templates/live/config/live_pg.rb +60 -0
- data/lib/monk/templates/mail/Gemfile.extra +1 -0
- data/lib/monk/templates/mail/config/mail.rb +19 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/errors.rb +7 -0
- data/lib/monk/websocket/pg_fanout.rb +174 -0
- metadata +27 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
require "pg"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Monk
|
|
5
|
+
module WebSocket
|
|
6
|
+
# Cross-process fan-out for Registry, opt-in: require
|
|
7
|
+
# "monk/websocket/pg_fanout" explicitly -- "monk/websocket" alone does
|
|
8
|
+
# not load this. A second implementation of the exact interface
|
|
9
|
+
# RedisFanout wraps a Registry behind (#register, #unregister, #count,
|
|
10
|
+
# #broadcast, docs/design/live-pg-fanout.md,
|
|
11
|
+
# docs/history/plan-live-pg-fanout.md), so an app swaps which object it
|
|
12
|
+
# holds and nothing else changes. Uses Postgres LISTEN/NOTIFY instead
|
|
13
|
+
# of Redis pub/sub -- for an app that already runs Postgres
|
|
14
|
+
# (persistence, Monk::Auth) and doesn't need Redis's higher throughput
|
|
15
|
+
# or its lack of a payload cap (see the design doc's "Where this is
|
|
16
|
+
# worse than Redis").
|
|
17
|
+
class PgFanout
|
|
18
|
+
# Postgres has no pattern LISTEN (unlike Redis's own
|
|
19
|
+
# psubscribe("monk:ws:*")), so every key shares one fixed channel;
|
|
20
|
+
# the key rides inside the notification payload instead of the
|
|
21
|
+
# channel name -- see the subscriber Ractor in #initialize below
|
|
22
|
+
# for the other half of this.
|
|
23
|
+
CHANNEL = "monk_live".freeze
|
|
24
|
+
|
|
25
|
+
# Postgres's own hard limit on a single NOTIFY payload -- verified
|
|
26
|
+
# directly against a real server (not assumed from documentation):
|
|
27
|
+
# 7999 bytes succeeds, exactly 8000 fails with "payload string too
|
|
28
|
+
# long". #broadcast checks the full envelope (origin + key + app
|
|
29
|
+
# payload) against this *exclusive* bound before ever calling
|
|
30
|
+
# pg_notify, so an oversized broadcast fails with a clear
|
|
31
|
+
# PayloadTooLargeError instead of a raw PG::Error surfacing from
|
|
32
|
+
# inside the query.
|
|
33
|
+
MAX_NOTIFY_PAYLOAD_BYTES = 8000
|
|
34
|
+
|
|
35
|
+
# Inverse of #envelope_for. A class method, not a private instance
|
|
36
|
+
# method like its encode-side counterpart: the subscriber Ractor
|
|
37
|
+
# below has no `self` bound to a PgFanout instance to call an
|
|
38
|
+
# ordinary method on, only whatever it can reach off the class
|
|
39
|
+
# constant (mirrors Monk::WebSocket::Server.serve being a class
|
|
40
|
+
# method for the equivalent reason -- the Server instance itself
|
|
41
|
+
# never crosses into a connection's Ractor either).
|
|
42
|
+
def self.decode_envelope(raw)
|
|
43
|
+
bytes = raw.b
|
|
44
|
+
origin_len_str, rest = bytes.split(":", 2)
|
|
45
|
+
origin_len = origin_len_str.to_i
|
|
46
|
+
origin = rest[0, origin_len]
|
|
47
|
+
rest = rest[origin_len..]
|
|
48
|
+
key_len_str, rest = rest.split(":", 2)
|
|
49
|
+
key_len = key_len_str.to_i
|
|
50
|
+
key = rest[0, key_len]
|
|
51
|
+
payload = rest[key_len..]
|
|
52
|
+
[origin, key, payload]
|
|
53
|
+
end
|
|
54
|
+
|
|
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.
|
|
65
|
+
unless Ractor.shareable?(registry)
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"Monk::WebSocket::PgFanout.new needs a Ractor-shareable registry, got #{registry.class}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@registry = registry
|
|
71
|
+
# Ractor.make_shareable, not a plain #freeze: pg_opts's values
|
|
72
|
+
# (host/user/password Strings from ENV.fetch, typically) aren't
|
|
73
|
+
# frozen individually just because the Hash itself is -- #freeze
|
|
74
|
+
# only locks the Hash against further key/value changes, it
|
|
75
|
+
# doesn't recurse. Without this, the shareability check below
|
|
76
|
+
# fails on this Hash's contents, not on the registry argument it's
|
|
77
|
+
# meant to guard, the same trap Server#initialize's
|
|
78
|
+
# allowed_origins already avoids the same way.
|
|
79
|
+
@pg_opts = Ractor.make_shareable(pg_opts.dup)
|
|
80
|
+
# Frozen explicitly, not just a String literal: read from every
|
|
81
|
+
# calling Ractor's own #broadcast and from the subscriber Ractor
|
|
82
|
+
# below, both of which raise Ractor::IsolationError on an
|
|
83
|
+
# unfrozen value -- same reason RedisFanout freezes its own
|
|
84
|
+
# @origin.
|
|
85
|
+
@origin = SecureRandom.uuid.freeze
|
|
86
|
+
|
|
87
|
+
# Ractor.new's block args must be shareable: registry is
|
|
88
|
+
# (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
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def register(key, port) = @registry.register(key, port)
|
|
110
|
+
def unregister(key, port) = @registry.unregister(key, port)
|
|
111
|
+
def count(key) = @registry.count(key)
|
|
112
|
+
|
|
113
|
+
def broadcast(key, payload)
|
|
114
|
+
# Delivers locally first, regardless of what happens next -- same
|
|
115
|
+
# latency and reliability as a plain Registry if Postgres is
|
|
116
|
+
# briefly unavailable or this payload is over the wire cap,
|
|
117
|
+
# mirroring RedisFanout#broadcast's own ordering.
|
|
118
|
+
@registry.broadcast(key, payload)
|
|
119
|
+
|
|
120
|
+
envelope = envelope_for(key, payload)
|
|
121
|
+
if envelope.bytesize >= MAX_NOTIFY_PAYLOAD_BYTES
|
|
122
|
+
raise Monk::WebSocket::PayloadTooLargeError,
|
|
123
|
+
"Monk::WebSocket::PgFanout#broadcast payload plus origin/key framing is " \
|
|
124
|
+
"#{envelope.bytesize} bytes; Postgres NOTIFY accepts a payload of at most " \
|
|
125
|
+
"#{MAX_NOTIFY_PAYLOAD_BYTES - 1} bytes -- shrink the fragment, or use " \
|
|
126
|
+
"Monk::WebSocket::RedisFanout instead, which has no such cap"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# pg_notify(channel, payload), not a string-built
|
|
130
|
+
# "NOTIFY channel, 'payload'" -- passing the envelope as a bound
|
|
131
|
+
# parameter sidesteps any quoting/injection concern for its bytes
|
|
132
|
+
# entirely, unlike Redis's PUBLISH, which never had that concern
|
|
133
|
+
# in the first place.
|
|
134
|
+
publisher.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Length-prefixes the origin and key (Netstring-style: "<bytesize>:"
|
|
140
|
+
# then that many bytes) instead of RedisFanout's NUL-separated
|
|
141
|
+
# "origin\0payload" -- discovered while building this that a NUL
|
|
142
|
+
# byte can't ride in a Postgres NOTIFY payload at all (a bound text
|
|
143
|
+
# parameter containing one raises "string contains null byte" from
|
|
144
|
+
# inside exec_params; text NOTIFY payloads simply don't carry one).
|
|
145
|
+
# A digit run followed by ':' can never be confused with content
|
|
146
|
+
# coming later: the header is always the *first* ':' in the whole
|
|
147
|
+
# string, no matter what colons, NULs or anything else an app's key
|
|
148
|
+
# or payload contains.
|
|
149
|
+
#
|
|
150
|
+
# Forces binary (.b) throughout so slicing by the recorded byte
|
|
151
|
+
# lengths lines up exactly -- the same fix Monk::WebSocket::Frame.encode
|
|
152
|
+
# needed for non-ASCII payloads (docs/history/plan-live.md Phase 7);
|
|
153
|
+
# a String#[] by character count on a UTF-8 string would misalign
|
|
154
|
+
# the moment origin/key/payload aren't pure ASCII.
|
|
155
|
+
def envelope_for(key, payload)
|
|
156
|
+
origin_bytes = @origin.b
|
|
157
|
+
key_bytes = key.to_s.b
|
|
158
|
+
"#{origin_bytes.bytesize}:".b + origin_bytes + "#{key_bytes.bytesize}:".b + key_bytes + payload.to_s.b
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One PG::Connection per calling Ractor, opened on first use and
|
|
162
|
+
# cached there -- a single client called concurrently from every
|
|
163
|
+
# connection Ractor's #broadcast wouldn't be safe, mirrors
|
|
164
|
+
# RedisFanout#publisher and Monk::Persistence::Pg's own per-Ractor
|
|
165
|
+
# pattern exactly. Never the connection Monk::Persistence::Pg hands
|
|
166
|
+
# out for app queries: NOTIFY issued on a connection mid-transaction
|
|
167
|
+
# wouldn't fire until that transaction commits, so this stays a
|
|
168
|
+
# connection of its own, used for nothing but one-off notifies.
|
|
169
|
+
def publisher
|
|
170
|
+
Ractor.current[:monk_pg_fanout_publisher] ||= PG.connect(**@pg_opts)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: monkrb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matteo Folin
|
|
@@ -79,6 +79,20 @@ dependencies:
|
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
81
|
version: '3.2'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: net-smtp
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.5'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.5'
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
97
|
name: minitest
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -175,6 +189,13 @@ files:
|
|
|
175
189
|
- lib/monk/live/renderer.rb
|
|
176
190
|
- lib/monk/live/session.rb
|
|
177
191
|
- lib/monk/log.rb
|
|
192
|
+
- lib/monk/mail.rb
|
|
193
|
+
- lib/monk/mail/address.rb
|
|
194
|
+
- lib/monk/mail/errors.rb
|
|
195
|
+
- lib/monk/mail/message.rb
|
|
196
|
+
- lib/monk/mail/mime.rb
|
|
197
|
+
- lib/monk/mail/transports.rb
|
|
198
|
+
- lib/monk/mail/url.rb
|
|
178
199
|
- lib/monk/persistence.rb
|
|
179
200
|
- lib/monk/persistence/errors.rb
|
|
180
201
|
- lib/monk/persistence/model.rb
|
|
@@ -188,6 +209,7 @@ files:
|
|
|
188
209
|
- lib/monk/templates/auth/config/auth.rb
|
|
189
210
|
- lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql
|
|
190
211
|
- lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql
|
|
212
|
+
- lib/monk/templates/auth/views/mail/magic_link.erb
|
|
191
213
|
- lib/monk/templates/base/.dockerignore
|
|
192
214
|
- lib/monk/templates/base/.gitignore
|
|
193
215
|
- lib/monk/templates/base/.ruby-version
|
|
@@ -204,8 +226,11 @@ files:
|
|
|
204
226
|
- lib/monk/templates/live/bin/websocket_server
|
|
205
227
|
- lib/monk/templates/live/config.ru
|
|
206
228
|
- lib/monk/templates/live/config/live.rb
|
|
229
|
+
- lib/monk/templates/live/config/live_pg.rb
|
|
207
230
|
- lib/monk/templates/live/views/index.erb
|
|
208
231
|
- lib/monk/templates/live/views/live/_hits.erb
|
|
232
|
+
- lib/monk/templates/mail/Gemfile.extra
|
|
233
|
+
- lib/monk/templates/mail/config/mail.rb
|
|
209
234
|
- lib/monk/templates/postgres/Dockerfile
|
|
210
235
|
- lib/monk/templates/postgres/Gemfile.extra
|
|
211
236
|
- lib/monk/templates/postgres/bin/console
|
|
@@ -220,6 +245,7 @@ files:
|
|
|
220
245
|
- lib/monk/websocket/errors.rb
|
|
221
246
|
- lib/monk/websocket/frame.rb
|
|
222
247
|
- lib/monk/websocket/handshake.rb
|
|
248
|
+
- lib/monk/websocket/pg_fanout.rb
|
|
223
249
|
- lib/monk/websocket/redis_fanout.rb
|
|
224
250
|
- lib/monk/websocket/registry.rb
|
|
225
251
|
- lib/monk/websocket/server.rb
|