monkrb 0.19.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.
@@ -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