monkrb 0.19.0 → 0.21.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 +166 -0
- data/README.md +7 -6
- data/exe/monk +3 -181
- data/lib/monk/assets.rb +39 -9
- data/lib/monk/auth/errors.rb +3 -0
- data/lib/monk/auth/helpers.rb +11 -0
- data/lib/monk/auth/send_login_link.rb +36 -0
- data/lib/monk/auth.rb +34 -5
- data/lib/monk/base.rb +20 -0
- data/lib/monk/bin.rb +107 -0
- data/lib/monk/cli.rb +277 -0
- data/lib/monk/context.rb +17 -0
- data/lib/monk/errors.rb +0 -6
- data/lib/monk/generator/definition.rb +153 -0
- data/lib/monk/generator/result.rb +279 -0
- data/lib/monk/generator/run.rb +449 -0
- data/lib/monk/generator.rb +48 -0
- data/lib/monk/generators/auth.rb +29 -0
- data/lib/monk/generators/base.rb +19 -0
- data/lib/monk/generators/jobs.rb +21 -0
- data/lib/monk/generators/live.rb +26 -0
- data/lib/monk/generators/mail.rb +24 -0
- data/lib/monk/generators/postgres.rb +23 -0
- data/lib/monk/generators/redis.rb +20 -0
- data/lib/monk/generators/websocket.rb +24 -0
- data/lib/monk/generators.rb +7 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +17 -4
- data/lib/monk/log.rb +10 -1
- data/lib/monk/mail/deliver_later.rb +54 -0
- data/lib/monk/mail/later.rb +3 -51
- data/lib/monk/mail.rb +4 -0
- 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/templates/auth/agents.md +29 -0
- data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
- data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
- data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
- data/lib/monk/templates/auth/config/auth.rb +14 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +4 -3
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +8 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -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 +57 -16
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +83 -14
- data/lib/monk/scaffold.rb +0 -1061
- data/lib/monk/templates/base/bin/websocket_server +0 -68
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
- data/lib/monk/templates/live/app/app.rb +0 -22
- data/lib/monk/templates/live/app/views/index.erb +0 -18
- data/lib/monk/templates/live/bin/websocket_server +0 -42
- data/lib/monk/templates/live/config/live_pg.rb +0 -60
- data/lib/monk/templates/mail/Gemfile.extra +0 -1
- data/lib/monk/templates/postgres/Dockerfile +0 -32
- data/lib/monk/templates/postgres/Gemfile.extra +0 -2
- data/lib/monk/templates/redis/Gemfile.extra +0 -1
- /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# The WebSocket server process, over a transport between processes:
|
|
2
|
+
# Postgres or Redis.
|
|
3
|
+
Monk::Generator.define(:websocket) do
|
|
4
|
+
summary "WebSocket server process"
|
|
5
|
+
installed_if "config/websocket.rb"
|
|
6
|
+
option :transport, values: %w[postgres redis], dependency: true,
|
|
7
|
+
describe: { postgres: "LISTEN/NOTIFY, nothing else to run; 8 KB per broadcast",
|
|
8
|
+
redis: "no size limit; Redis must be running", }
|
|
9
|
+
|
|
10
|
+
copy "config/websocket.rb", role: :wiring,
|
|
11
|
+
from: ->(options) { "websocket/config/websocket_#{options[:transport]}.rb" }
|
|
12
|
+
copy "bin/websocket_server", role: :wiring, executable: true
|
|
13
|
+
copy "app/sockets/chat.rb", role: :example
|
|
14
|
+
copy "test/websocket_test.rb", role: :test
|
|
15
|
+
|
|
16
|
+
env :example, { "WS_PORT" => "9293" }
|
|
17
|
+
env :example, { "WS_ALLOWED_ORIGINS" => "https://example.com" }, commented: true
|
|
18
|
+
|
|
19
|
+
setup_section "websocket/setup.md"
|
|
20
|
+
agents_section "websocket/agents.md"
|
|
21
|
+
set_before_production "WS_ALLOWED_ORIGINS", "defaults to PUBLIC_URL; list every origin your pages use"
|
|
22
|
+
next_step "bin/websocket_server", note: "beside bin/server"
|
|
23
|
+
example "websocket-chat", todo: "uncomment for one chat room, or write your own handler in app/sockets/"
|
|
24
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
require_relative "generator"
|
|
2
|
+
|
|
3
|
+
# Monk's own modules, one generator each (docs/adr/0017), in dependency
|
|
4
|
+
# order only for readability: the registry resolves dependencies itself.
|
|
5
|
+
%w[base postgres redis mail auth jobs websocket live].each do |name|
|
|
6
|
+
require_relative "generators/#{name}"
|
|
7
|
+
end
|
|
@@ -247,10 +247,12 @@ module Monk
|
|
|
247
247
|
# Ractor's connection is still running on the server, then
|
|
248
248
|
# reconnects, so the worker's next query doesn't wait for the
|
|
249
249
|
# abandoned one (Phase 0.3) or land inside its open transaction.
|
|
250
|
+
# The cancel stays here: that connection is alive, just busy, so
|
|
251
|
+
# checkout's own probe wouldn't reset it.
|
|
250
252
|
def reset_connection
|
|
251
253
|
conn = Monk::Persistence::Pg[@db_name]
|
|
252
254
|
conn.cancel
|
|
253
|
-
conn
|
|
255
|
+
Monk::Persistence::Pg.revive(conn)
|
|
254
256
|
nil
|
|
255
257
|
end
|
|
256
258
|
|
data/lib/monk/jobs.rb
CHANGED
|
@@ -227,3 +227,10 @@ module Monk
|
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
require_relative "jobs/job"
|
|
230
|
+
|
|
231
|
+
# What jobs provide together with another module, when that module is
|
|
232
|
+
# loaded too -- either order works (lib/monk/mail.rb and lib/monk/auth.rb
|
|
233
|
+
# end the same way), so an app's config files never require these
|
|
234
|
+
# themselves (docs/adr/0017).
|
|
235
|
+
require_relative "mail/deliver_later" if Monk.const_defined?(:Mail)
|
|
236
|
+
require_relative "auth/send_login_link" if Monk.const_defined?(:Auth)
|
data/lib/monk/live/helpers.rb
CHANGED
|
@@ -17,6 +17,19 @@ module Monk
|
|
|
17
17
|
|
|
18
18
|
raw(%(data-live-topic="#{names.join(" ")}"))
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# For the layout's <%= monk_head %>: where the client opens its
|
|
22
|
+
# WebSocket (live_ws_url, which config/live.rb declares) and the
|
|
23
|
+
# client runtime itself. Nothing until Live is configured, i.e. until
|
|
24
|
+
# config/live.rb has run.
|
|
25
|
+
def head_tags
|
|
26
|
+
return super unless Monk::Live.registry
|
|
27
|
+
|
|
28
|
+
super + [
|
|
29
|
+
%(<meta name="monk-live-url" content="#{h(settings[:live_ws_url])}">),
|
|
30
|
+
%(<script type="module" src="#{h(asset_path("#{Live::CLIENT_PATH}/monk_live.js"))}"></script>),
|
|
31
|
+
]
|
|
32
|
+
end
|
|
20
33
|
end
|
|
21
34
|
end
|
|
22
35
|
end
|
data/lib/monk/live/policy.rb
CHANGED
|
@@ -7,8 +7,16 @@ module Monk
|
|
|
7
7
|
# the matching rule says `anonymous: true`: a block like
|
|
8
8
|
# `topic == "contacts:#{subject}"` would otherwise let a nil subject
|
|
9
9
|
# in through "contacts:". A block that raises fails closed.
|
|
10
|
+
#
|
|
11
|
+
# A rule with db_pool: runs its block in that Monk::Persistence::Pg
|
|
12
|
+
# pool (docs/history/plan-pg-pool.md), not in the socket's own Ractor,
|
|
13
|
+
# so a rule that queries the database doesn't open a connection per
|
|
14
|
+
# open page. The block crosses to the pool as it is: rules are
|
|
15
|
+
# shareable procs already. If the pool itself fails (not started in
|
|
16
|
+
# this process, timed out, full), the rule denies and the reason is
|
|
17
|
+
# logged.
|
|
10
18
|
module Policy
|
|
11
|
-
Rule = Data.define(:pattern, :anonymous, :check) do
|
|
19
|
+
Rule = Data.define(:pattern, :anonymous, :check, :db_pool) do
|
|
12
20
|
def matches?(topic)
|
|
13
21
|
if pattern.end_with?("*")
|
|
14
22
|
topic.start_with?(pattern.chomp("*"))
|
|
@@ -18,14 +26,17 @@ module Monk
|
|
|
18
26
|
end
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
def self.build_rule(pattern, anonymous, block)
|
|
29
|
+
def self.build_rule(pattern, anonymous, block, db_pool = nil)
|
|
22
30
|
unless pattern.is_a?(String) && !pattern.strip.empty? && valid_wildcard?(pattern)
|
|
23
31
|
raise ArgumentError,
|
|
24
32
|
"pattern must be a topic or a topic prefix ending in a single *, got #{pattern.inspect}"
|
|
25
33
|
end
|
|
26
34
|
raise ArgumentError, "authorize needs a block: { |subject, topic| ... }" unless block
|
|
35
|
+
unless db_pool.nil? || db_pool.is_a?(Symbol)
|
|
36
|
+
raise ArgumentError, "db_pool must be a pool name (a Symbol), got #{db_pool.inspect}"
|
|
37
|
+
end
|
|
27
38
|
|
|
28
|
-
Rule.new(pattern: pattern.dup.freeze, anonymous: anonymous, check: shareable(block))
|
|
39
|
+
Rule.new(pattern: pattern.dup.freeze, anonymous: anonymous, check: shareable(block), db_pool: db_pool)
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def self.allowed?(rules, subject, topic)
|
|
@@ -33,11 +44,35 @@ module Monk
|
|
|
33
44
|
return false unless rule
|
|
34
45
|
return false if subject.nil? && !rule.anonymous
|
|
35
46
|
|
|
36
|
-
rule
|
|
47
|
+
run(rule, subject, topic) ? true : false
|
|
48
|
+
rescue Monk::PersistenceTimeoutError, Monk::PoolFullError, Monk::PoolNotStartedError, Monk::UnknownPoolError,
|
|
49
|
+
Monk::PoolWorkerDiedError, Monk::PoolStoppedError => e
|
|
50
|
+
log_pool_failure(rule, topic, e)
|
|
51
|
+
false
|
|
37
52
|
rescue StandardError
|
|
38
53
|
false
|
|
39
54
|
end
|
|
40
55
|
|
|
56
|
+
# What a pooled rule runs in the pool's worker.
|
|
57
|
+
def self.check(block, subject, topic) = block.call(subject, topic)
|
|
58
|
+
|
|
59
|
+
def self.run(rule, subject, topic)
|
|
60
|
+
return rule.check.call(subject, topic) unless rule.db_pool
|
|
61
|
+
|
|
62
|
+
Monk::Persistence::Pg.pool(rule.db_pool).call(Policy, :check, rule.check, subject, topic)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :run
|
|
65
|
+
|
|
66
|
+
def self.log_pool_failure(rule, topic, error)
|
|
67
|
+
Monk::Log.error(
|
|
68
|
+
"Monk::Live: denied #{topic} (rule #{rule.pattern}): its pool #{rule.db_pool.inspect} failed: " \
|
|
69
|
+
"#{error.class}: #{error.message}",
|
|
70
|
+
)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
private_class_method :log_pool_failure
|
|
75
|
+
|
|
41
76
|
def self.valid_wildcard?(pattern)
|
|
42
77
|
pattern.count("*").zero? || (pattern.count("*") == 1 && pattern.end_with?("*"))
|
|
43
78
|
end
|
data/lib/monk/live/session.rb
CHANGED
|
@@ -95,6 +95,13 @@ module Monk
|
|
|
95
95
|
seq = 0
|
|
96
96
|
loop do
|
|
97
97
|
payload = @port.receive
|
|
98
|
+
# Registry#close_all: broadcasts were missed, so the client must
|
|
99
|
+
# reconnect and resync (ADR 0011). run's read loop ends, and
|
|
100
|
+
# cleanup unregisters every topic.
|
|
101
|
+
if payload.is_a?(Monk::WebSocket::CloseRequest)
|
|
102
|
+
return @connection.close(code: payload.code, reason: payload.reason)
|
|
103
|
+
end
|
|
104
|
+
|
|
98
105
|
seq += 1
|
|
99
106
|
@connection.write(payload.start_with?("{") ? "{\"seq\":#{seq},#{payload.byteslice(1..)}" : payload)
|
|
100
107
|
end
|
data/lib/monk/live.rb
CHANGED
|
@@ -35,14 +35,25 @@ module Monk
|
|
|
35
35
|
).run
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# A rule block that lets everyone in, for Monk::Live.authorize:
|
|
39
|
+
# Monk::Live.authorize("demo:hits", anonymous: true, &Monk::Live::ALLOW_ALL)
|
|
40
|
+
# A block written at the top of config/live.rb isn't Ractor-shareable
|
|
41
|
+
# (self there is the main object), so rules need one built here.
|
|
42
|
+
ALLOW_ALL = Ractor.make_shareable(proc { |_subject, _topic| true })
|
|
43
|
+
|
|
38
44
|
CLIENT_DIR = File.expand_path("live/client", __dir__).freeze
|
|
45
|
+
# Where Monk serves CLIENT_DIR from: the layout's <%= monk_head %> loads
|
|
46
|
+
# CLIENT_PATH/monk_live.js (Live::Helpers#head_tags), so an app never
|
|
47
|
+
# copies the client and never serves an older copy than its gem.
|
|
48
|
+
CLIENT_PATH = "/_monk/live".freeze
|
|
49
|
+
Monk::Assets.mount(CLIENT_PATH, CLIENT_DIR)
|
|
39
50
|
|
|
40
51
|
class << self
|
|
41
52
|
attr_reader :publisher, :registry, :rules, :max_topics
|
|
42
53
|
|
|
43
54
|
# Where the browser runtime ships inside the gem: monk_live.js,
|
|
44
55
|
# protocol.js and idiomorph.js (they import each other by relative
|
|
45
|
-
# path, so
|
|
56
|
+
# path, so they're served as a unit, from CLIENT_PATH).
|
|
46
57
|
def client_dir = CLIENT_DIR
|
|
47
58
|
|
|
48
59
|
# Boot-time, main Ractor. `registry` is a Monk::WebSocket::Registry,
|
|
@@ -74,9 +85,11 @@ module Monk
|
|
|
74
85
|
|
|
75
86
|
# Boot-time, main Ractor. See Monk::Live::Policy for the semantics:
|
|
76
87
|
# deny by default, first matching rule wins, anonymous subjects need
|
|
77
|
-
# `anonymous: true`. The block must be Ractor-shareable.
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
# `anonymous: true`. The block must be Ractor-shareable. db_pool:
|
|
89
|
+
# names a Monk::Persistence::Pg pool to run the block in, for a rule
|
|
90
|
+
# that queries the database; the WebSocket process must start it.
|
|
91
|
+
def authorize(pattern, anonymous: false, db_pool: nil, &block)
|
|
92
|
+
@rules = Ractor.make_shareable([*@rules, Policy.build_rule(pattern, anonymous, block, db_pool)])
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
def authorized?(subject, topic) = Policy.allowed?(rules, subject, topic)
|
data/lib/monk/log.rb
CHANGED
|
@@ -118,7 +118,16 @@ module Monk
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
def handle
|
|
121
|
-
Ractor.current[:monk_log_handle] ||= File.open(@path, "a")
|
|
121
|
+
Ractor.current[:monk_log_handle] ||= File.open(@path || path_before_boot, "a")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Before Boot -- bin/console, a script that loads config/load.rb and
|
|
125
|
+
# never boots -- nothing has frozen the path yet. Worked out here
|
|
126
|
+
# instead, the way #freeze_registry! does: only the main Ractor runs
|
|
127
|
+
# before Boot, so reading Monk.env and writing log/ is safe.
|
|
128
|
+
def path_before_boot
|
|
129
|
+
FileUtils.mkdir_p(root)
|
|
130
|
+
File.join(root, "#{Monk.env}.log")
|
|
122
131
|
end
|
|
123
132
|
end
|
|
124
133
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Monk::Mail.deliver_later and its job. Loaded at the end of whichever of
|
|
2
|
+
# monk/mail and monk/jobs is required second, once both are defined
|
|
3
|
+
# (docs/adr/0017), so it requires neither itself.
|
|
4
|
+
|
|
5
|
+
module Monk
|
|
6
|
+
module Mail
|
|
7
|
+
# Sends one message from a job process (docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
|
|
8
|
+
# Enqueued by Monk::Mail.deliver_later, never directly: its argument
|
|
9
|
+
# is the message's fields, already checked and with from: resolved.
|
|
10
|
+
class DeliveryJob < Monk::Job
|
|
11
|
+
queue "mailers"
|
|
12
|
+
never_retry PermanentDeliveryError
|
|
13
|
+
|
|
14
|
+
# The SMTP failures that are final, by class name so this file never
|
|
15
|
+
# loads net/smtp itself (the app's Gemfile decides that, ADR 0012):
|
|
16
|
+
# a 5xx reply, a malformed command, refused credentials. Everything
|
|
17
|
+
# else -- a 4xx "try later", a refused connection, TLS, a timeout --
|
|
18
|
+
# is worth retrying.
|
|
19
|
+
PERMANENT_SMTP_ERRORS = %w[Net::SMTPFatalError Net::SMTPSyntaxError Net::SMTPAuthenticationError].freeze
|
|
20
|
+
|
|
21
|
+
def self.perform(fields)
|
|
22
|
+
Monk::Mail.deliver(**fields.transform_keys(&:to_sym))
|
|
23
|
+
rescue DeliveryError => e
|
|
24
|
+
raise unless PERMANENT_SMTP_ERRORS.include?(e.cause&.class&.name)
|
|
25
|
+
|
|
26
|
+
raise PermanentDeliveryError, e.message
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# deliver, later: the same arguments, plus enqueue's wait:, at: and
|
|
32
|
+
# conn: (the last one enqueues inside the caller's transaction, so the
|
|
33
|
+
# email commits or rolls back with the app's own writes). Returns the
|
|
34
|
+
# job's id. The message is built -- and so checked -- here, before
|
|
35
|
+
# anything is enqueued: bad input raises InvalidMessageError in the
|
|
36
|
+
# caller, exactly as deliver would, rather than failing later in the
|
|
37
|
+
# job process. Render HTML before calling (html: Monk::Mail.render(...)):
|
|
38
|
+
# the job stores the finished message and only sends it.
|
|
39
|
+
#
|
|
40
|
+
# Monk::Mail.deliver_later(to: order[:email], subject: "Your receipt", text: receipt_text)
|
|
41
|
+
def deliver_later(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil, wait: nil, at: nil, conn: nil)
|
|
42
|
+
raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver_later" unless config
|
|
43
|
+
|
|
44
|
+
from ||= config[:from]
|
|
45
|
+
if from.nil?
|
|
46
|
+
raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
|
|
50
|
+
DeliveryJob.enqueue(message.to_h.transform_keys(&:to_s), wait: wait, at: at, conn: conn)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/monk/mail/later.rb
CHANGED
|
@@ -1,53 +1,5 @@
|
|
|
1
|
+
# Monk::Mail.deliver_later (docs/adr/0014). Loading monk/mail and
|
|
2
|
+
# monk/jobs is enough -- the second one loads it (docs/adr/0017) -- so
|
|
3
|
+
# this file only exists for apps whose config/jobs.rb still requires it.
|
|
1
4
|
require_relative "../mail"
|
|
2
5
|
require_relative "../jobs"
|
|
3
|
-
|
|
4
|
-
module Monk
|
|
5
|
-
module Mail
|
|
6
|
-
# Sends one message from a job process (docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
|
|
7
|
-
# Enqueued by Monk::Mail.deliver_later, never directly: its argument
|
|
8
|
-
# is the message's fields, already checked and with from: resolved.
|
|
9
|
-
class DeliveryJob < Monk::Job
|
|
10
|
-
queue "mailers"
|
|
11
|
-
never_retry PermanentDeliveryError
|
|
12
|
-
|
|
13
|
-
# The SMTP failures that are final, by class name so this file never
|
|
14
|
-
# loads net/smtp itself (the app's Gemfile decides that, ADR 0012):
|
|
15
|
-
# a 5xx reply, a malformed command, refused credentials. Everything
|
|
16
|
-
# else -- a 4xx "try later", a refused connection, TLS, a timeout --
|
|
17
|
-
# is worth retrying.
|
|
18
|
-
PERMANENT_SMTP_ERRORS = %w[Net::SMTPFatalError Net::SMTPSyntaxError Net::SMTPAuthenticationError].freeze
|
|
19
|
-
|
|
20
|
-
def self.perform(fields)
|
|
21
|
-
Monk::Mail.deliver(**fields.transform_keys(&:to_sym))
|
|
22
|
-
rescue DeliveryError => e
|
|
23
|
-
raise unless PERMANENT_SMTP_ERRORS.include?(e.cause&.class&.name)
|
|
24
|
-
|
|
25
|
-
raise PermanentDeliveryError, e.message
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
class << self
|
|
30
|
-
# deliver, later: the same arguments, plus enqueue's wait:, at: and
|
|
31
|
-
# conn: (the last one enqueues inside the caller's transaction, so the
|
|
32
|
-
# email commits or rolls back with the app's own writes). Returns the
|
|
33
|
-
# job's id. The message is built -- and so checked -- here, before
|
|
34
|
-
# anything is enqueued: bad input raises InvalidMessageError in the
|
|
35
|
-
# caller, exactly as deliver would, rather than failing later in the
|
|
36
|
-
# job process. Render HTML before calling (html: Monk::Mail.render(...)):
|
|
37
|
-
# the job stores the finished message and only sends it.
|
|
38
|
-
#
|
|
39
|
-
# Monk::Mail.deliver_later(to: order[:email], subject: "Your receipt", text: receipt_text)
|
|
40
|
-
def deliver_later(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil, wait: nil, at: nil, conn: nil)
|
|
41
|
-
raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver_later" unless config
|
|
42
|
-
|
|
43
|
-
from ||= config[:from]
|
|
44
|
-
if from.nil?
|
|
45
|
-
raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
|
|
49
|
-
DeliveryJob.enqueue(message.to_h.transform_keys(&:to_s), wait: wait, at: at, conn: conn)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
data/lib/monk/mail.rb
CHANGED
|
@@ -119,3 +119,7 @@ module Monk
|
|
|
119
119
|
Monk.freeze_hooks << self
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
|
+
|
|
123
|
+
# Monk::Mail.deliver_later, when jobs are loaded too -- either order works
|
|
124
|
+
# (lib/monk/jobs.rb ends the same way, docs/adr/0017).
|
|
125
|
+
require_relative "mail/deliver_later" if Monk.const_defined?(:Jobs)
|
|
@@ -4,4 +4,36 @@ module Monk
|
|
|
4
4
|
|
|
5
5
|
class UnknownPersistenceError < StandardError
|
|
6
6
|
end
|
|
7
|
+
|
|
8
|
+
# Monk::Persistence::Pg.pool(name) for a pool never declared.
|
|
9
|
+
class UnknownPoolError < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Monk::Persistence::Pg.pool(name) for a pool declared but not started
|
|
13
|
+
# in this process (start_pools!).
|
|
14
|
+
class PoolNotStartedError < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A pool call's method returned a value that can't be copied to the
|
|
18
|
+
# caller's Ractor (a PG::Result, say).
|
|
19
|
+
class PoolReturnError < StandardError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A pool's queue is full: as many calls as its queue: option allows are
|
|
23
|
+
# already waiting for a worker.
|
|
24
|
+
class PoolFullError < StandardError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The pool worker running a call died (not an exception from the called
|
|
28
|
+
# method, which the caller gets): the call may or may not have finished.
|
|
29
|
+
class PoolWorkerDiedError < StandardError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The pool has stopped: calls waiting when it stopped, and calls after.
|
|
33
|
+
class PoolStoppedError < StandardError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# start_pools! couldn't connect a pool's workers.
|
|
37
|
+
class PoolStartError < StandardError
|
|
38
|
+
end
|
|
7
39
|
end
|
data/lib/monk/persistence/pg.rb
CHANGED
|
@@ -36,11 +36,102 @@ module Monk
|
|
|
36
36
|
.tap { |types| %w[json jsonb].each { |name| types.register_type(0, name, nil, JsonDecoder) } },
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
+
# Without a connect_timeout, libpq waits indefinitely for a host that
|
|
40
|
+
# doesn't answer -- and #checkout's revive does so holding the
|
|
41
|
+
# Ractor's slot. The same 5 s as Registry's checkout timeout. An
|
|
42
|
+
# app's own connect_timeout: wins.
|
|
43
|
+
DEFAULT_CONNECT_TIMEOUT = 5
|
|
44
|
+
|
|
45
|
+
# Every diagnostic field libpq reports for an error (SQLSTATE,
|
|
46
|
+
# constraint, table, detail, hint, ...).
|
|
47
|
+
DIAG_FIELDS = PG.constants.grep(/\APG_DIAG_/).map { |name| PG.const_get(name) }.freeze
|
|
48
|
+
|
|
49
|
+
# What a PG::Error's #result becomes when the error leaves a pool's
|
|
50
|
+
# worker (#make_portable): a PG::Result is tied to its connection and
|
|
51
|
+
# can't be copied to another Ractor. Keeps what reading an error
|
|
52
|
+
# needs; anything else raises NoMethodError.
|
|
53
|
+
ErrorResult = Data.define(:fields, :error_message, :result_status, :res_status) do
|
|
54
|
+
def self.from(result)
|
|
55
|
+
fields = DIAG_FIELDS.to_h { |code| [code, result.error_field(code)] }.compact
|
|
56
|
+
new(
|
|
57
|
+
fields: fields, error_message: result.error_message,
|
|
58
|
+
result_status: result.result_status, res_status: result.res_status,
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def error_field(code) = fields[code]
|
|
63
|
+
alias_method :result_error_field, :error_field
|
|
64
|
+
alias_method :result_error_message, :error_message
|
|
65
|
+
end
|
|
66
|
+
|
|
39
67
|
class << self
|
|
68
|
+
# A copy of an exception about to leave a pool's worker
|
|
69
|
+
# (Pool::Portable): a PG::Error holds its connection and result,
|
|
70
|
+
# which can't be copied to another Ractor. The connection goes (no
|
|
71
|
+
# use in another Ractor anyway), and the result becomes an
|
|
72
|
+
# ErrorResult, so error_field still answers. The error keeps its
|
|
73
|
+
# class, so `rescue PG::UniqueViolation` matches as it would here.
|
|
74
|
+
def make_portable(error)
|
|
75
|
+
return unless error.is_a?(PG::Error)
|
|
76
|
+
|
|
77
|
+
result = error.result
|
|
78
|
+
error.instance_variable_set(:@connection, nil)
|
|
79
|
+
error.instance_variable_set(:@result, result && ErrorResult.from(result))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Detects a connection the server has closed (a restart, a
|
|
83
|
+
# failover, pg_terminate_backend). libpq still reports
|
|
84
|
+
# CONNECTION_OK, but the socket is readable: a healthy idle
|
|
85
|
+
# connection's isn't. So the common case costs one zero-timeout
|
|
86
|
+
# select, about 1 µs, and nothing sent.
|
|
87
|
+
#
|
|
88
|
+
# A readable socket is either the server's FATAL before it closes,
|
|
89
|
+
# or a NOTIFY on a connection that LISTENs. Reading on to tell
|
|
90
|
+
# them apart races the close: the FATAL can arrive a moment before
|
|
91
|
+
# the end of file, and a second select in between sees nothing.
|
|
92
|
+
# So a readable socket gets an empty query, one round trip, which
|
|
93
|
+
# fails on a closed connection and leaves pending notifies queued.
|
|
94
|
+
#
|
|
95
|
+
# A network path that dies without closing the socket (a NAT
|
|
96
|
+
# timing out an idle connection) leaves it unreadable, and this
|
|
97
|
+
# can't see it: the next query waits for TCP to give up.
|
|
98
|
+
#
|
|
99
|
+
# Public: PgFanout probes its own connections with it.
|
|
100
|
+
def alive?(conn)
|
|
101
|
+
return false unless conn.status == PG::CONNECTION_OK
|
|
102
|
+
return true unless conn.socket_io.wait_readable(0)
|
|
103
|
+
|
|
104
|
+
conn.exec("")
|
|
105
|
+
true
|
|
106
|
+
rescue PG::Error, IOError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Same connection object, new backend: the result type map set by
|
|
111
|
+
# #connect survives. Raises PG::ConnectionBad if Postgres is still
|
|
112
|
+
# unreachable; the next checkout tries again.
|
|
113
|
+
def revive(conn)
|
|
114
|
+
conn.reset
|
|
115
|
+
end
|
|
116
|
+
|
|
40
117
|
private
|
|
41
118
|
|
|
119
|
+
# A block that ran a raw BEGIN and raised leaves the connection in
|
|
120
|
+
# a transaction, and the next checkout in this Ractor would run
|
|
121
|
+
# inside it. (conn.transaction rolls back by itself.) Skipped for
|
|
122
|
+
# PQTRANS_ACTIVE, a query still running: a ROLLBACK would first
|
|
123
|
+
# wait for it. Monk::Jobs cancels those itself.
|
|
124
|
+
def release(conn)
|
|
125
|
+
return unless conn.status == PG::CONNECTION_OK
|
|
126
|
+
return unless [PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR].include?(conn.transaction_status)
|
|
127
|
+
|
|
128
|
+
conn.exec("ROLLBACK")
|
|
129
|
+
rescue PG::Error
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
42
133
|
def connect(**opts)
|
|
43
|
-
conn = PG.connect(**opts)
|
|
134
|
+
conn = PG.connect(connect_timeout: DEFAULT_CONNECT_TIMEOUT, **opts)
|
|
44
135
|
conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn, registry: RESULT_TYPES)
|
|
45
136
|
conn
|
|
46
137
|
end
|