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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /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.reset
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)
@@ -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
@@ -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.check.call(subject, topic) ? true : false
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
@@ -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 an app serves or copies the directory as a unit).
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
- def authorize(pattern, anonymous: false, &block)
79
- @rules = Ractor.make_shareable([*@rules, Policy.build_rule(pattern, anonymous, block)])
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
@@ -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
@@ -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