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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +2 -2
- data/lib/monk/base.rb +20 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +15 -3
- 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/scaffold.rb +155 -78
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +13 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +17 -2
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
- data/lib/monk/templates/jobs/bin/jobs +5 -13
- data/lib/monk/templates/jobs/config/jobs.rb +2 -4
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +23 -3
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +47 -0
- data/lib/monk/websocket/pg_fanout.rb +189 -31
- data/lib/monk/websocket/redis_fanout.rb +125 -33
- data/lib/monk/websocket/registry.rb +30 -0
- data/lib/monk/websocket/server.rb +128 -29
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +21 -9
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
- /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Persistence
|
|
3
|
+
# Named connection pools for short-lived Ractors
|
|
4
|
+
# (docs/history/plan-pg-pool.md). A PG::Connection can't be moved or
|
|
5
|
+
# shared across Ractors, so a pool lends nothing: a few worker Ractors
|
|
6
|
+
# own connections, and other Ractors send them calls to run.
|
|
7
|
+
#
|
|
8
|
+
# caller --[:call, receiver, method, args, kwargs, reply, deadline]--> dispatcher's inbox
|
|
9
|
+
# dispatcher --> an idle worker (or the queue, until one is idle)
|
|
10
|
+
# worker: receiver.public_send(method, *args, **kwargs), on its own connection
|
|
11
|
+
# worker --[:ok, value] or [:error, exception]--> reply; [:idle, port] --> inbox
|
|
12
|
+
#
|
|
13
|
+
# The dispatcher starts the workers and replaces one that dies.
|
|
14
|
+
module Pool
|
|
15
|
+
# A pool as declared with Registry#pool(name, ...). Frozen, so any
|
|
16
|
+
# Ractor can read it once boot has frozen the registry.
|
|
17
|
+
Config = Data.define(:name, :db, :size, :timeout, :queue)
|
|
18
|
+
|
|
19
|
+
# What #pool fills in for options a declaration leaves out. timeout:
|
|
20
|
+
# is the same 5 s as checkout's. queue: is how many calls may wait
|
|
21
|
+
# for a worker before #call refuses more: 4 workers clear 1000 in
|
|
22
|
+
# well under a second, so a full queue means a stalled database.
|
|
23
|
+
DEFAULTS = { db: :primary, size: 4, timeout: 5, queue: 1000 }.freeze
|
|
24
|
+
|
|
25
|
+
# Monk::Log writes to log/<env>.log once Monk has booted. A worker
|
|
26
|
+
# mustn't die of a failure to log, whatever state Monk::Log is in.
|
|
27
|
+
def self.log(level, message)
|
|
28
|
+
Monk::Log.public_send(level, "Monk::Persistence #{message}")
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A started pool, as Registry#pool(name) returns it: frozen, so it can
|
|
34
|
+
# be kept in a constant and used from any Ractor.
|
|
35
|
+
Handle = Data.define(:config, :inbox) do
|
|
36
|
+
# Runs receiver.public_send(method, *args, **kwargs) in one of the
|
|
37
|
+
# pool's workers, on that worker's connection, and returns its
|
|
38
|
+
# value or raises its exception. Everything after the method name
|
|
39
|
+
# belongs to the target method: call takes no options of its own.
|
|
40
|
+
#
|
|
41
|
+
# Raises Monk::PersistenceTimeoutError once the pool's timeout
|
|
42
|
+
# passes, whether the call is still queued (it then never runs) or
|
|
43
|
+
# running (it runs to the end: a timeout undoes nothing). Raises
|
|
44
|
+
# Monk::PoolFullError at once when the pool's queue is full.
|
|
45
|
+
#
|
|
46
|
+
# Raises Monk::PoolWorkerDiedError at once if the worker running it
|
|
47
|
+
# dies, and Monk::PoolStoppedError if the pool stops first.
|
|
48
|
+
#
|
|
49
|
+
# Made from inside one of this pool's own workers, it runs inline,
|
|
50
|
+
# on that worker's connection: queued, it could wait for the very
|
|
51
|
+
# worker making it.
|
|
52
|
+
def call(receiver, method, *args, **kwargs)
|
|
53
|
+
return receiver.public_send(method, *args, **kwargs) if Ractor.current[:monk_pool] == config.name
|
|
54
|
+
|
|
55
|
+
reply = Ractor::Port.new
|
|
56
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config.timeout
|
|
57
|
+
submit([:call, receiver, method, args, kwargs, reply, deadline])
|
|
58
|
+
status, value = reply.receive
|
|
59
|
+
case status
|
|
60
|
+
when :ok then value
|
|
61
|
+
when :error then raise_from_worker(value)
|
|
62
|
+
when :timeout then raise Monk::PersistenceTimeoutError, timeout_message(receiver, method)
|
|
63
|
+
when :full then raise Monk::PoolFullError, full_message
|
|
64
|
+
when :died then raise Monk::PoolWorkerDiedError, died_message(receiver, method)
|
|
65
|
+
when :stopped then raise Monk::PoolStoppedError, stopped_message
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
reply&.close
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Like #call, but returns nil as soon as the dispatcher has the call
|
|
72
|
+
# (microseconds), without waiting for it to run. Raises
|
|
73
|
+
# Monk::PoolFullError when the queue is full. The call has no
|
|
74
|
+
# deadline: it always runs, however long it waits. A failure while
|
|
75
|
+
# it runs is logged, since nobody is waiting for it.
|
|
76
|
+
#
|
|
77
|
+
# One sender's calls run in the order it made them only in a pool
|
|
78
|
+
# of size 1; a larger pool runs them side by side.
|
|
79
|
+
def call_async(receiver, method, *args, **kwargs)
|
|
80
|
+
ack = Ractor::Port.new
|
|
81
|
+
submit([:call, receiver, method, args, kwargs, ack, nil])
|
|
82
|
+
status, = ack.receive
|
|
83
|
+
raise Monk::PoolFullError, full_message if status == :full
|
|
84
|
+
raise Monk::PoolStoppedError, stopped_message if status == :stopped
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
ack&.close
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# The dispatcher's inbox closes when it ends.
|
|
94
|
+
def submit(call)
|
|
95
|
+
inbox.send(call)
|
|
96
|
+
rescue Ractor::ClosedError
|
|
97
|
+
raise Monk::PoolStoppedError, stopped_message
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def died_message(receiver, method)
|
|
101
|
+
"the pool #{config.name.inspect} worker running #{receiver}.#{method} died; " \
|
|
102
|
+
"the call may or may not have finished (the worker has been replaced)"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stopped_message = "pool #{config.name.inspect} has stopped"
|
|
106
|
+
|
|
107
|
+
# The worker's frames, then the caller's: a port drops a
|
|
108
|
+
# backtrace, so the worker sent its own as strings.
|
|
109
|
+
def raise_from_worker(error)
|
|
110
|
+
error.set_backtrace([*error.backtrace, "(in pool #{config.name.inspect}; called from)", *caller(2)])
|
|
111
|
+
raise error
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def timeout_message(receiver, method)
|
|
115
|
+
"#{receiver}.#{method} in pool #{config.name.inspect} didn't finish within #{config.timeout}s " \
|
|
116
|
+
"(if it was already running, it runs to the end: a timeout undoes nothing)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def full_message
|
|
120
|
+
"pool #{config.name.inspect} already has #{config.queue} calls waiting for its " \
|
|
121
|
+
"#{config.size} worker(s): the database is stalled, or the pool is too small"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A pool's dispatcher, inside its own Ractor. It exists because a
|
|
126
|
+
# port can only be read by the Ractor that created it: the workers
|
|
127
|
+
# can't share an inbox, so this one holds it, with the idle workers,
|
|
128
|
+
# the calls waiting for one, and the calls running.
|
|
129
|
+
#
|
|
130
|
+
# Timeouts are its job too: a ticker wakes it, and it answers
|
|
131
|
+
# :timeout to every caller whose deadline has passed, so a caller
|
|
132
|
+
# needs no timer of its own. A queued call that timed out is dropped
|
|
133
|
+
# and never runs.
|
|
134
|
+
#
|
|
135
|
+
# It starts the workers, and watches them: one that dies is logged
|
|
136
|
+
# and replaced at once, and its caller answered :died. A replacement
|
|
137
|
+
# that can't connect is retried with backoff, calls waiting in the
|
|
138
|
+
# queue meanwhile. It is never restarted itself, since every handle,
|
|
139
|
+
# possibly kept in a constant, holds its inbox: it survives a
|
|
140
|
+
# failure handling a message, and if it ends anyway, it answers
|
|
141
|
+
# every caller still waiting :stopped, so none hangs.
|
|
142
|
+
class Dispatcher
|
|
143
|
+
RETRY_INITIAL = 0.5
|
|
144
|
+
RETRY_MAX = 30
|
|
145
|
+
|
|
146
|
+
# Reports [:ok, inbox] on `ready` once every worker has connected,
|
|
147
|
+
# or [:error, why] after stopping them.
|
|
148
|
+
def self.run(backend, config, ready)
|
|
149
|
+
dispatcher = new(backend, config, Ractor::Port.new)
|
|
150
|
+
failure = dispatcher.start_workers
|
|
151
|
+
return dispatcher.report_failure(ready, failure) if failure
|
|
152
|
+
|
|
153
|
+
ready.send([:ok, dispatcher.inbox])
|
|
154
|
+
dispatcher.run
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
attr_reader :inbox
|
|
158
|
+
|
|
159
|
+
def initialize(backend, config, inbox)
|
|
160
|
+
@backend = backend
|
|
161
|
+
@config = config
|
|
162
|
+
@inbox = inbox
|
|
163
|
+
@workers = {} # index => { ractor:, port:, failure: }
|
|
164
|
+
@idle = []
|
|
165
|
+
@waiting = []
|
|
166
|
+
@running = {} # worker port => its call, or nil once its caller timed out
|
|
167
|
+
@retry_delay = {} # index => seconds before the next attempt
|
|
168
|
+
@respawn_at = {} # index => when to try again
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Blocks until each worker has connected or failed to; returns the
|
|
172
|
+
# first failure, or nil.
|
|
173
|
+
def start_workers
|
|
174
|
+
@config.size.times { |index| spawn_worker(index) }
|
|
175
|
+
pending = @config.size
|
|
176
|
+
failure = nil
|
|
177
|
+
while pending.positive?
|
|
178
|
+
message = @inbox.receive
|
|
179
|
+
case message.first
|
|
180
|
+
when :ready then ready(*message.drop(1))
|
|
181
|
+
when :failed then failure ||= message[2]
|
|
182
|
+
when :idle then @idle.push(message[1])
|
|
183
|
+
end
|
|
184
|
+
pending -= 1 if %i[ready failed].include?(message.first)
|
|
185
|
+
end
|
|
186
|
+
failure
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def report_failure(ready, failure)
|
|
190
|
+
stop_workers
|
|
191
|
+
ready.send([:error, failure])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def run
|
|
195
|
+
ticker = start_ticker
|
|
196
|
+
loop do
|
|
197
|
+
message = @inbox.receive
|
|
198
|
+
break if message.first == :stop
|
|
199
|
+
|
|
200
|
+
handle(message)
|
|
201
|
+
dispatch
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
log(:error, "dispatcher: #{e.class}: #{e.message}; carrying on")
|
|
204
|
+
end
|
|
205
|
+
ensure
|
|
206
|
+
ticker&.kill
|
|
207
|
+
stop_workers
|
|
208
|
+
release_callers
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def handle(message)
|
|
214
|
+
case message.first
|
|
215
|
+
when :call then accept(message)
|
|
216
|
+
when :idle then idle(message[1])
|
|
217
|
+
when :tick then tick
|
|
218
|
+
when :ready then ready(*message.drop(1))
|
|
219
|
+
when :failed then @workers[message[1]]&.store(:failure, message[2])
|
|
220
|
+
when :died then died(*message.drop(1))
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A call message: [:call, receiver, method, args, kwargs, reply,
|
|
225
|
+
# deadline]. An async call has no deadline, and its reply port only
|
|
226
|
+
# hears whether it was accepted.
|
|
227
|
+
def reply_of(call) = call[5]
|
|
228
|
+
def deadline_of(call) = call[6]
|
|
229
|
+
def describe(call) = "#{call[1]}.#{call[2]}"
|
|
230
|
+
|
|
231
|
+
def accept(call)
|
|
232
|
+
return answer(call, [:full]) if @waiting.size >= @config.queue
|
|
233
|
+
|
|
234
|
+
@waiting.push(call)
|
|
235
|
+
answer(call, [:accepted]) unless deadline_of(call)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def past?(call, now)
|
|
239
|
+
deadline = deadline_of(call)
|
|
240
|
+
deadline && deadline < now
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def idle(worker)
|
|
244
|
+
@running.delete(worker)
|
|
245
|
+
@idle.push(worker)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Oldest call first. A worker whose port is closed is dropped, and
|
|
249
|
+
# the call waits for the next one.
|
|
250
|
+
def dispatch
|
|
251
|
+
while @idle.any? && @waiting.any?
|
|
252
|
+
worker = @idle.pop
|
|
253
|
+
call = @waiting.first
|
|
254
|
+
next unless hand(worker, call)
|
|
255
|
+
|
|
256
|
+
@waiting.shift
|
|
257
|
+
@running[worker] = call
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def tick
|
|
262
|
+
now = monotonic
|
|
263
|
+
expire(now)
|
|
264
|
+
due = @respawn_at.select { |_, at| at <= now }.keys
|
|
265
|
+
due.each do |index|
|
|
266
|
+
@respawn_at.delete(index)
|
|
267
|
+
spawn_worker(index)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def expire(now)
|
|
272
|
+
expired, @waiting = @waiting.partition { |call| past?(call, now) }
|
|
273
|
+
expired.each { |call| answer(call, [:timeout]) }
|
|
274
|
+
@running.each do |worker, call|
|
|
275
|
+
next unless call && past?(call, now)
|
|
276
|
+
|
|
277
|
+
answer(call, [:timeout])
|
|
278
|
+
@running[worker] = nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def spawn_worker(index)
|
|
283
|
+
ractor = Ractor.new(@backend, @config, @inbox, index, name: "monk-pool-#{@config.name}-#{index}") do |*args|
|
|
284
|
+
Monk::Persistence::Pool::Worker.run(*args)
|
|
285
|
+
end
|
|
286
|
+
@workers[index] = { ractor: ractor, port: nil, failure: nil }
|
|
287
|
+
watch(index, ractor)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def ready(index, port)
|
|
291
|
+
worker = @workers[index]
|
|
292
|
+
return unless worker
|
|
293
|
+
|
|
294
|
+
log(:info, "worker #{index} connected again") if @retry_delay.delete(index)
|
|
295
|
+
worker[:port] = port
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A thread per worker turns its Ractor ending into a message here.
|
|
299
|
+
def watch(index, ractor)
|
|
300
|
+
inbox = @inbox
|
|
301
|
+
Thread.new do
|
|
302
|
+
port = Ractor::Port.new
|
|
303
|
+
ractor.monitor(port)
|
|
304
|
+
inbox.send([:died, index, ractor, port.receive])
|
|
305
|
+
rescue Ractor::ClosedError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def died(index, ractor, reason)
|
|
311
|
+
worker = @workers[index]
|
|
312
|
+
return if @stopping || worker.nil? || !worker[:ractor].equal?(ractor)
|
|
313
|
+
|
|
314
|
+
port = worker[:port]
|
|
315
|
+
return retry_later(index, worker[:failure]) unless port
|
|
316
|
+
|
|
317
|
+
@idle.delete(port)
|
|
318
|
+
call = @running.delete(port)
|
|
319
|
+
doing = call ? "running #{describe(call)}" : "idle"
|
|
320
|
+
log(:error, "worker #{index} died (#{reason}) while #{doing}, starting a new one: #{worker[:failure]}")
|
|
321
|
+
answer(call, [:died]) if call && deadline_of(call)
|
|
322
|
+
spawn_worker(index)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def retry_later(index, failure)
|
|
326
|
+
delay = @retry_delay[index] || RETRY_INITIAL
|
|
327
|
+
log(:warn, "worker #{index} couldn't connect (#{failure}); retrying in #{delay}s")
|
|
328
|
+
@retry_delay[index] = [delay * 2, RETRY_MAX].min
|
|
329
|
+
@respawn_at[index] = monotonic + delay
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Every worker finishes the call in hand first. Two loops: all are
|
|
333
|
+
# told before any is waited for, so they stop side by side.
|
|
334
|
+
def stop_workers
|
|
335
|
+
@stopping = true
|
|
336
|
+
@workers.each_value do |worker|
|
|
337
|
+
worker[:port]&.send(:stop)
|
|
338
|
+
rescue Ractor::ClosedError
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
@workers.each_value { |worker| worker[:ractor].join } # rubocop:disable Style/CombinableLoops
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def release_callers
|
|
345
|
+
(@waiting + @running.values.compact).each { |call| answer(call, [:stopped]) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def start_ticker
|
|
349
|
+
interval = (@config.timeout / 10.0).clamp(0.01, 0.1)
|
|
350
|
+
inbox = @inbox
|
|
351
|
+
Thread.new do
|
|
352
|
+
loop do
|
|
353
|
+
sleep interval
|
|
354
|
+
inbox.send([:tick])
|
|
355
|
+
end
|
|
356
|
+
rescue Ractor::ClosedError
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def answer(call, message)
|
|
362
|
+
reply_of(call).send(message)
|
|
363
|
+
rescue Ractor::ClosedError
|
|
364
|
+
nil # the caller stopped waiting
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# False when the worker's port is closed (the worker ended).
|
|
368
|
+
def hand(worker, call)
|
|
369
|
+
worker.send(call)
|
|
370
|
+
true
|
|
371
|
+
rescue Ractor::ClosedError
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
376
|
+
|
|
377
|
+
def log(level, message) = Pool.log(level, "pool #{@config.name.inspect}: #{message}")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# The body of a pool's worker Ractor: an ordinary Ractor whose
|
|
381
|
+
# connection the ordinary checkout opens and keeps, with its
|
|
382
|
+
# reconnect and rollback. A called method's own checkout gets it.
|
|
383
|
+
module Worker
|
|
384
|
+
def self.run(backend, config, inbox, index)
|
|
385
|
+
# The dispatcher logs why a worker died, from what it sends
|
|
386
|
+
# below; don't also dump it to stderr.
|
|
387
|
+
Thread.current.report_on_exception = false
|
|
388
|
+
Ractor.current[:monk_pool] = config.name
|
|
389
|
+
port = Ractor::Port.new
|
|
390
|
+
begin
|
|
391
|
+
backend.checkout(config.db) { nil }
|
|
392
|
+
rescue StandardError => e
|
|
393
|
+
return inbox.send([:failed, index, "#{e.class}: #{e.message}"])
|
|
394
|
+
end
|
|
395
|
+
inbox.send([:ready, index, port])
|
|
396
|
+
|
|
397
|
+
loop do
|
|
398
|
+
inbox.send([:idle, port])
|
|
399
|
+
call = port.receive
|
|
400
|
+
break if call == :stop
|
|
401
|
+
|
|
402
|
+
_, receiver, method, args, kwargs, reply, deadline = call
|
|
403
|
+
next perform_async(config, receiver, method, args, kwargs) unless deadline
|
|
404
|
+
|
|
405
|
+
perform(backend, receiver, method, args, kwargs, reply)
|
|
406
|
+
end
|
|
407
|
+
rescue Ractor::ClosedError
|
|
408
|
+
nil # the dispatcher has stopped
|
|
409
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
410
|
+
# Not a called method's StandardError (its caller gets those): a
|
|
411
|
+
# bug, or an Exception from deep in a C extension. Tell the
|
|
412
|
+
# dispatcher why, then end: it starts a new worker.
|
|
413
|
+
report_death(inbox, index, e)
|
|
414
|
+
raise
|
|
415
|
+
ensure
|
|
416
|
+
backend.disconnect_all
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def self.report_death(inbox, index, error)
|
|
420
|
+
trace = Array(error.backtrace).first(5).map { |frame| "\n #{frame}" }.join
|
|
421
|
+
inbox.send([:failed, index, "#{error.class}: #{error.message}#{trace}"])
|
|
422
|
+
rescue Ractor::ClosedError
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def self.perform(backend, receiver, method, args, kwargs, reply)
|
|
427
|
+
result =
|
|
428
|
+
begin
|
|
429
|
+
[:ok, receiver.public_send(method, *args, **kwargs)]
|
|
430
|
+
rescue StandardError => e
|
|
431
|
+
[:error, Portable.exception(e, backend)]
|
|
432
|
+
end
|
|
433
|
+
return if deliver(reply, result)
|
|
434
|
+
|
|
435
|
+
# The copy to the caller's Ractor failed: answer with something
|
|
436
|
+
# that can always be copied, so the caller isn't left waiting.
|
|
437
|
+
status, value = result
|
|
438
|
+
fallback = status == :ok ? Portable.return_error(receiver, method, value) : Portable.summary(value)
|
|
439
|
+
deliver(reply, [:error, fallback])
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Nobody waits for an async call's result: only its failure is
|
|
443
|
+
# reported, in the log.
|
|
444
|
+
def self.perform_async(config, receiver, method, args, kwargs)
|
|
445
|
+
receiver.public_send(method, *args, **kwargs)
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
trace = Array(e.backtrace).first(10).map { |frame| "\n #{frame}" }.join
|
|
448
|
+
what = "#{receiver}.#{method} failed: #{e.class}: #{e.message}"
|
|
449
|
+
Pool.log(:error, "pool #{config.name.inspect}: #{what}#{trace}")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# False when the result can't be copied to the caller's Ractor.
|
|
453
|
+
# Not only Ractor::Error: copying a PG::Result raises TypeError
|
|
454
|
+
# ("allocator undefined"), and an escape here would end the worker
|
|
455
|
+
# with its caller still waiting.
|
|
456
|
+
def self.deliver(reply, result)
|
|
457
|
+
reply.send(result)
|
|
458
|
+
true
|
|
459
|
+
rescue Ractor::ClosedError
|
|
460
|
+
true # the caller stopped waiting: nothing to answer
|
|
461
|
+
rescue StandardError
|
|
462
|
+
false
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Copies of what a worker sends back, fit to cross to the caller's
|
|
467
|
+
# Ractor.
|
|
468
|
+
module Portable
|
|
469
|
+
# Longer cause chains are cut to a summary of the next cause.
|
|
470
|
+
MAX_CAUSES = 5
|
|
471
|
+
|
|
472
|
+
# A copy of error with its class, message, own instance variables
|
|
473
|
+
# and backtrace, and its cause chain copied the same way. The
|
|
474
|
+
# backend drops what can't be copied (Pg: the connection and
|
|
475
|
+
# result a PG::Error holds). The backtrace is set as strings: a
|
|
476
|
+
# port drops it otherwise.
|
|
477
|
+
def self.exception(error, backend, depth = 0)
|
|
478
|
+
copy = error.dup
|
|
479
|
+
backend.make_portable(copy)
|
|
480
|
+
copy.set_backtrace(Array(error.backtrace))
|
|
481
|
+
return copy unless error.cause
|
|
482
|
+
|
|
483
|
+
cause = depth < MAX_CAUSES ? exception(error.cause, backend, depth + 1) : summary(error.cause)
|
|
484
|
+
with_cause(copy, cause)
|
|
485
|
+
rescue StandardError
|
|
486
|
+
summary(error)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Exception has no cause=: raising sets it, and keeps the backtrace
|
|
490
|
+
# already set.
|
|
491
|
+
def self.with_cause(error, cause)
|
|
492
|
+
raise error, cause: cause
|
|
493
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
494
|
+
e
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# For an exception that still can't be copied.
|
|
498
|
+
def self.summary(error)
|
|
499
|
+
RuntimeError.new("#{error.class}: #{error.message} (it couldn't leave the pool)").tap do |copy|
|
|
500
|
+
copy.set_backtrace(Array(error.backtrace))
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def self.return_error(receiver, method, value)
|
|
505
|
+
Monk::PoolReturnError.new(
|
|
506
|
+
"#{receiver}.#{method} returned a #{value.class}, which can't leave the pool: " \
|
|
507
|
+
"return plain data (rows as Hashes, not a PG::Result)",
|
|
508
|
+
)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|