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.
@@ -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.15.1
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