monkrb 0.18.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +2 -2
  4. data/lib/monk/base.rb +20 -0
  5. data/lib/monk/jobs/adapters/pg.rb +3 -1
  6. data/lib/monk/live/client/monk_live.js +2 -2
  7. data/lib/monk/live/client/protocol.js +5 -0
  8. data/lib/monk/live/policy.rb +39 -4
  9. data/lib/monk/live/session.rb +7 -0
  10. data/lib/monk/live.rb +15 -3
  11. data/lib/monk/persistence/errors.rb +32 -0
  12. data/lib/monk/persistence/pg.rb +92 -1
  13. data/lib/monk/persistence/pool.rb +513 -0
  14. data/lib/monk/persistence.rb +196 -7
  15. data/lib/monk/scaffold.rb +155 -78
  16. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  17. data/lib/monk/templates/auth/config/auth.rb +13 -20
  18. data/lib/monk/templates/base/app/app.rb +9 -0
  19. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  20. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  21. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  22. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  23. data/lib/monk/templates/base/app/models/.keep +0 -0
  24. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  25. data/lib/monk/templates/base/bin/websocket_server +17 -2
  26. data/lib/monk/templates/base/config/load.rb +18 -0
  27. data/lib/monk/templates/base/config.ru +3 -12
  28. data/lib/monk/templates/base/public/js/app.js +1 -1
  29. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  30. data/lib/monk/templates/jobs/bin/jobs +5 -13
  31. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  32. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  33. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  34. data/lib/monk/templates/live/bin/websocket_server +23 -3
  35. data/lib/monk/templates/live/config/live.rb +1 -1
  36. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  37. data/lib/monk/templates/mail/config/mail.rb +2 -2
  38. data/lib/monk/templates/postgres/bin/console +4 -2
  39. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  40. data/lib/monk/version.rb +1 -1
  41. data/lib/monk/websocket/connection.rb +25 -6
  42. data/lib/monk/websocket/errors.rb +11 -0
  43. data/lib/monk/websocket/listeners.rb +47 -0
  44. data/lib/monk/websocket/pg_fanout.rb +189 -31
  45. data/lib/monk/websocket/redis_fanout.rb +125 -33
  46. data/lib/monk/websocket/registry.rb +30 -0
  47. data/lib/monk/websocket/server.rb +128 -29
  48. data/lib/monk/websocket.rb +2 -0
  49. data/lib/monk.rb +10 -0
  50. metadata +21 -9
  51. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  52. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  53. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  54. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  55. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
@@ -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
- e.slot << true if token
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
- Ractor.current[:monk_persistence]&.each_value do |e|
70
- disconnect(e.conn)
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