raptor 0.10.0 → 0.12.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.
@@ -72,6 +72,10 @@ module Raptor
72
72
  # @rbs @worker_timeout: Integer
73
73
  # @rbs @worker_drain_timeout: Integer
74
74
  # @rbs @worker_shutdown_timeout: Integer
75
+ # @rbs @before_fork: Array[Proc]
76
+ # @rbs @before_worker_boot: Array[Proc]
77
+ # @rbs @before_worker_shutdown: Array[Proc]
78
+ # @rbs @before_refork: Array[Proc]
75
79
  # @rbs @stats_file: String?
76
80
  # @rbs @pid_file: String?
77
81
  # @rbs @stdout_file: String?
@@ -92,6 +96,16 @@ module Raptor
92
96
  # @rbs @phased_restart_requested: bool
93
97
  # @rbs @phased_restarting: bool
94
98
  # @rbs @hot_restart_requested: bool
99
+ # @rbs @refork_thresholds: Array[Integer]
100
+ # @rbs @refork_requested: bool
101
+ # @rbs @refork_threshold_idx: Integer
102
+ # @rbs @seed_pid: Integer?
103
+ # @rbs @seed_ready: bool
104
+ # @rbs @seed_vacated_index: Integer?
105
+ # @rbs @fork_r: IO?
106
+ # @rbs @fork_w: IO?
107
+ # @rbs @resp_r: IO?
108
+ # @rbs @resp_w: IO?
95
109
  # @rbs @bpf_active: bool
96
110
 
97
111
  # Creates a new Cluster with the specified configuration.
@@ -118,6 +132,11 @@ module Raptor
118
132
  # @option options [Integer] :worker_timeout seconds to wait for a booted worker to check in before killing it
119
133
  # @option options [Integer] :worker_drain_timeout seconds a worker waits for in-flight requests during shutdown before force-killing app threads
120
134
  # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
135
+ # @option options [Integer, Array<Integer>, nil] :refork_after request-count threshold(s) at which a warm worker is promoted to a fork source for phased refork; nil or 0 disables. Requires `PR_SET_CHILD_SUBREAPER` (Linux)
136
+ # @option options [Array<Proc>] :before_fork procs called in the master before every worker fork
137
+ # @option options [Array<Proc>] :before_worker_boot procs called in each worker before it begins serving
138
+ # @option options [Array<Proc>] :before_worker_shutdown procs called in each worker before its graceful shutdown
139
+ # @option options [Array<Proc>] :before_refork procs called in a worker before it transitions to a seed
121
140
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
122
141
  # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
123
142
  # @option options [String, nil] :stdout_file path to redirect stdout to, reopened on SIGHUP, or nil to disable
@@ -142,6 +161,10 @@ module Raptor
142
161
  @worker_timeout = options[:worker_timeout]
143
162
  @worker_drain_timeout = options[:worker_drain_timeout]
144
163
  @worker_shutdown_timeout = options[:worker_shutdown_timeout]
164
+ @before_fork = Array(options[:before_fork])
165
+ @before_worker_boot = Array(options[:before_worker_boot])
166
+ @before_worker_shutdown = Array(options[:before_worker_shutdown])
167
+ @before_refork = Array(options[:before_refork])
145
168
  @stats_file = options[:stats_file]
146
169
  @pid_file = options[:pid_file]
147
170
  @stdout_file = options[:stdout_file]
@@ -175,6 +198,13 @@ module Raptor
175
198
  @phased_restart_requested = false
176
199
  @phased_restarting = false
177
200
  @hot_restart_requested = false
201
+
202
+ @refork_thresholds = normalize_refork_thresholds(options[:refork_after])
203
+ @refork_requested = false
204
+ @refork_threshold_idx = 0
205
+ @seed_pid = nil
206
+ @seed_ready = false
207
+ @seed_vacated_index = nil
178
208
  end
179
209
 
180
210
  # Starts the multi-process cluster and manages worker processes.
@@ -196,6 +226,9 @@ module Raptor
196
226
  #
197
227
  # @rbs () -> void
198
228
  def run
229
+ $stdout.sync = true
230
+ $stderr.sync = true
231
+
199
232
  reopen_logs
200
233
 
201
234
  trap("INT") { shutdown }
@@ -204,6 +237,17 @@ module Raptor
204
237
  trap("USR1") { @phased_restart_requested = true }
205
238
  trap("USR2") { @hot_restart_requested = true }
206
239
 
240
+ if @refork_thresholds.any?
241
+ if Subreaper.enable
242
+ @fork_r, @fork_w = IO.pipe
243
+ @resp_r, @resp_w = IO.pipe
244
+ trap("URG") { @refork_requested = true }
245
+ else
246
+ Log.warn "Ignoring refork_after: PR_SET_CHILD_SUBREAPER not supported on this platform"
247
+ @refork_thresholds = [].freeze
248
+ end
249
+ end
250
+
207
251
  File.open(@pid_file, File::CREAT | File::EXCL | File::WRONLY) { |file| file.write(Process.pid.to_s) } if @pid_file
208
252
 
209
253
  @bpf_active = ReuseportBPF.setup(@worker_count)
@@ -224,7 +268,9 @@ module Raptor
224
268
  break if reap_workers == :no_children
225
269
 
226
270
  perform_hot_restart if @hot_restart_requested
271
+ poll_seed_ready if @seed_pid && !@seed_ready
227
272
  perform_phased_restart if @phased_restart_requested && !@phased_restarting
273
+ check_refork_trigger if @refork_thresholds.any? && !@phased_restarting
228
274
  timeout_hung_workers
229
275
 
230
276
  sleep 0.1
@@ -270,61 +316,256 @@ module Raptor
270
316
  bind_uris.zip(filenos).to_h { |bind_uri, fileno| [bind_uri, [fileno]] }
271
317
  end
272
318
 
273
- # Forks a new worker process and registers it at the given index.
274
- # The worker inherits the cluster's current phase.
319
+ # Normalises the `refork_after` option into a sorted array of positive
320
+ # thresholds. Accepts nil, 0, an Integer, or an Array<Integer>; anything
321
+ # else falls back to an empty array (feature disabled).
322
+ #
323
+ # @param value [Integer, Array<Integer>, nil] the raw option value
324
+ # @return [Array<Integer>]
325
+ #
326
+ # @rbs (untyped value) -> Array[Integer]
327
+ def normalize_refork_thresholds(value)
328
+ case value
329
+ when Integer
330
+ value.positive? ? [value].freeze : [].freeze
331
+ when Array
332
+ value.select { |threshold| threshold.is_a?(Integer) && threshold.positive? }.sort.freeze
333
+ else
334
+ [].freeze
335
+ end
336
+ end
337
+
338
+ # Forks a new worker process and registers it at the given index,
339
+ # forking from the seed when one is active and off the master otherwise.
275
340
  #
276
341
  # @param index [Integer] slot index for this worker in the stats region
277
342
  # @return [void]
278
343
  #
279
344
  # @rbs (Integer index) -> void
280
345
  def spawn_worker(index)
346
+ if @seed_pid && pid_alive?(@seed_pid)
347
+ pid = spawn_worker_via_seed(index)
348
+ if pid
349
+ @workers[index] = pid
350
+ return
351
+ end
352
+ Log.warn "Seed (#{@seed_pid}) failed to fork worker #{index}, falling back to direct fork"
353
+ @seed_pid = nil
354
+ end
355
+
356
+ @before_fork.each(&:call)
281
357
  pid = fork { run_worker(index, @phase) }
282
358
  @workers[index] = pid
283
359
  end
284
360
 
361
+ # Asks the seed to fork a new worker at the given index, returning the
362
+ # child pid, or nil when the seed doesn't respond in time.
363
+ #
364
+ # @param index [Integer] slot index for the new worker
365
+ # @return [Integer, nil] the forked worker's pid, or nil on failure
366
+ #
367
+ # @rbs (Integer index) -> Integer?
368
+ def spawn_worker_via_seed(index)
369
+ @fork_w.write([index, @phase].pack("LL"))
370
+ return nil unless @resp_r.wait_readable(5)
371
+
372
+ bytes = @resp_r.read_nonblock(4, exception: false)
373
+ return nil unless bytes.is_a?(String) && bytes.bytesize == 4
374
+
375
+ bytes.unpack1("L")
376
+ rescue Errno::EPIPE, IOError
377
+ nil
378
+ end
379
+
380
+ # Checks whether a process with the given pid is currently alive.
381
+ #
382
+ # @param pid [Integer] the pid to probe
383
+ # @return [Boolean] true if the process exists
384
+ #
385
+ # @rbs (Integer pid) -> bool
386
+ def pid_alive?(pid)
387
+ Process.kill(0, pid)
388
+ true
389
+ rescue Errno::ESRCH, Errno::ECHILD
390
+ false
391
+ rescue Errno::EPERM
392
+ true
393
+ end
394
+
285
395
  # Reaps any worker processes that have exited, respawning each one
286
396
  # unless the cluster is shutting down.
287
397
  #
288
- # @return [Symbol] :no_children when there are no remaining children, otherwise :reaped
398
+ # @return [Symbol] :no_children when nothing is left to supervise, otherwise :reaped
289
399
  #
290
400
  # @rbs () -> Symbol
291
401
  def reap_workers
292
402
  loop do
293
403
  pid, status = Process.wait2(-1, Process::WNOHANG)
294
- return :reaped unless pid
404
+ break unless pid
295
405
 
296
- index = @workers.key(pid)
297
- @workers.delete(index)
298
- @timed_out.delete(pid)
299
-
300
- unless @shutdown
301
- Log.warn "Restarting worker #{index} (#{pid}), #{exit_description(status)}"
302
- spawn_worker(index)
303
- end
406
+ reap_pid(pid, status)
304
407
  end
408
+
409
+ @workers.empty? && !@seed_pid ? :no_children : :reaped
305
410
  rescue Errno::ECHILD
306
- :no_children
411
+ @workers.empty? && !@seed_pid ? :no_children : :reaped
412
+ end
413
+
414
+ # Records a reaped pid, respawning its worker slot unless the cluster
415
+ # is shutting down. Clears the seed reference when the seed exits.
416
+ #
417
+ # @param pid [Integer] the reaped pid
418
+ # @param status [Process::Status] the exit status
419
+ # @return [void]
420
+ #
421
+ # @rbs (Integer pid, Process::Status status) -> void
422
+ def reap_pid(pid, status)
423
+ if pid == @seed_pid
424
+ Log.info "Seed (#{pid}) exited, #{exit_description(status)}"
425
+ @seed_pid = nil
426
+ return
427
+ end
428
+
429
+ index = @workers.key(pid)
430
+ return unless index
431
+
432
+ @workers.delete(index)
433
+ @timed_out.delete(pid)
434
+
435
+ unless @shutdown
436
+ Log.warn "Restarting worker #{index} (#{pid}), #{exit_description(status)}"
437
+ spawn_worker(index)
438
+ end
439
+ end
440
+
441
+ # Promotes the most-experienced worker to a seed and starts a phased
442
+ # refork when the next `refork_after` threshold is crossed or a manual
443
+ # refork was requested via `SIGURG`.
444
+ #
445
+ # @return [void]
446
+ #
447
+ # @rbs () -> void
448
+ def check_refork_trigger
449
+ candidate = pick_refork_candidate
450
+ return unless candidate
451
+
452
+ candidate_index, candidate_requests = candidate
453
+ threshold = @refork_thresholds[@refork_threshold_idx]
454
+
455
+ if @refork_requested
456
+ @refork_requested = false
457
+ elsif !threshold || candidate_requests < threshold
458
+ return
459
+ else
460
+ @refork_threshold_idx += 1
461
+ end
462
+
463
+ promote_worker_to_seed(candidate_index)
464
+ end
465
+
466
+ # Picks the most-experienced booted worker in the current phase,
467
+ # returning its slot index and its request count. Returns nil when
468
+ # no worker qualifies.
469
+ #
470
+ # @return [Array<Integer>, nil]
471
+ #
472
+ # @rbs () -> Array[Integer]?
473
+ def pick_refork_candidate
474
+ best_index = nil
475
+ best_requests = -1
476
+ @stats.all.each_with_index do |stat, index|
477
+ next unless @workers[index] == stat[:pid]
478
+ next unless stat[:booted]
479
+ next unless stat[:phase] == @phase
480
+ next unless stat[:requests] > best_requests
481
+
482
+ best_index = index
483
+ best_requests = stat[:requests]
484
+ end
485
+ [best_index, best_requests] if best_index
486
+ end
487
+
488
+ # Retires the current seed and promotes the given worker into its place,
489
+ # queueing a phased refork for the remaining workers.
490
+ #
491
+ # @param index [Integer] slot index of the worker to promote
492
+ # @return [void]
493
+ #
494
+ # @rbs (Integer index) -> void
495
+ def promote_worker_to_seed(index)
496
+ pid = @workers[index]
497
+ return unless pid
498
+
499
+ retire_current_seed
500
+ Log.info "Promoting worker #{index} to seed for phased refork"
501
+ ReuseportBPF.mark_unavailable(index) if @bpf_active
502
+ Process.kill("URG", pid) rescue nil
503
+ @workers.delete(index)
504
+ @seed_pid = pid
505
+ @seed_ready = false
506
+ @seed_vacated_index = index
507
+ @phased_restart_requested = true
307
508
  end
308
509
 
309
- # Stops every worker, escalating from TERM to KILL if any fail to
310
- # exit within `worker_shutdown_timeout`.
510
+ # Terminates the currently-active seed process, if any, and waits for
511
+ # it to exit. Its seed-forked workers stay attached to the master and
512
+ # keep serving.
513
+ #
514
+ # @return [void]
515
+ #
516
+ # @rbs () -> void
517
+ def retire_current_seed
518
+ return unless @seed_pid && pid_alive?(@seed_pid)
519
+
520
+ Log.info "Retiring seed (#{@seed_pid})"
521
+ Process.kill("TERM", @seed_pid) rescue nil
522
+
523
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @worker_shutdown_timeout
524
+ while @seed_pid && pid_alive?(@seed_pid)
525
+ reap_workers
526
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
527
+
528
+ sleep 0.05
529
+ end
530
+ end
531
+
532
+ # Records the seed's readiness when its ready marker has arrived.
533
+ # Non-blocking.
534
+ #
535
+ # @return [void]
536
+ #
537
+ # @rbs () -> void
538
+ def poll_seed_ready
539
+ return unless @resp_r && @seed_pid && !@seed_ready
540
+ return unless @resp_r.wait_readable(0)
541
+
542
+ bytes = @resp_r.read_nonblock(4, exception: false)
543
+ return unless bytes.is_a?(String) && bytes.bytesize == 4
544
+
545
+ @seed_ready = true if bytes.unpack1("L") == 0
546
+ end
547
+
548
+ # Stops every worker (and the seed if one is active), escalating
549
+ # from TERM to KILL if any fail to exit within `worker_shutdown_timeout`.
311
550
  #
312
551
  # @return [void]
313
552
  #
314
553
  # @rbs () -> void
315
554
  def stop_workers
316
555
  @workers.values.each { |pid| Process.kill("TERM", pid) rescue nil }
556
+ Process.kill("TERM", @seed_pid) rescue nil if @seed_pid
317
557
 
318
558
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @worker_shutdown_timeout
319
- until @workers.empty? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
559
+ until (@workers.empty? && !@seed_pid) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
320
560
  reap_workers
321
561
  sleep 0.05
322
562
  end
323
- return if @workers.empty?
563
+ return if @workers.empty? && !@seed_pid
324
564
 
325
- Log.warn "Force-killing #{@workers.size} worker(s) after #{@worker_shutdown_timeout}s"
326
- @workers.values.each { |pid| Process.kill("KILL", pid) rescue nil }
327
- @workers.values.each { |pid| Process.wait(pid) rescue nil }
565
+ pids = @workers.values + [@seed_pid].compact
566
+ Log.warn "Force-killing #{pids.size} process(es) after #{@worker_shutdown_timeout}s"
567
+ pids.each { |pid| Process.kill("KILL", pid) rescue nil }
568
+ pids.each { |pid| Process.wait(pid) rescue nil }
328
569
  end
329
570
 
330
571
  # Kills workers that have stopped checking in. A booted worker that
@@ -370,8 +611,12 @@ module Raptor
370
611
  Log.info "Phased restart starting"
371
612
 
372
613
  begin
614
+ wait_for_seed_ready
615
+ filled_index = fill_vacated_seed_slot
616
+
373
617
  @workers.keys.sort.each do |index|
374
618
  return if @shutdown
619
+ next if index == filled_index
375
620
 
376
621
  target_pid = @workers[index]
377
622
  next unless target_pid
@@ -382,7 +627,8 @@ module Raptor
382
627
  until @shutdown
383
628
  reap_workers
384
629
  current = @workers[index]
385
- break if current && current != target_pid && @stats.all[index][:booted]
630
+ stat = @stats.all[index]
631
+ break if current && current != target_pid && stat[:pid] == current && stat[:booted]
386
632
  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
387
633
 
388
634
  sleep 0.1
@@ -395,6 +641,48 @@ module Raptor
395
641
  end
396
642
  end
397
643
 
644
+ # Blocks until the freshly-promoted seed has signalled readiness,
645
+ # or times out and clears the seed reference. No-op when no seed is
646
+ # being promoted.
647
+ #
648
+ # @return [void]
649
+ #
650
+ # @rbs () -> void
651
+ def wait_for_seed_ready
652
+ return unless @seed_pid && !@seed_ready
653
+
654
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @worker_drain_timeout + 5
655
+ until @seed_ready || @shutdown
656
+ break unless pid_alive?(@seed_pid)
657
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
658
+
659
+ poll_seed_ready
660
+ sleep 0.1
661
+ end
662
+
663
+ return if @seed_ready
664
+
665
+ Log.warn "Seed (#{@seed_pid}) didn't signal ready in time, falling back to direct forks"
666
+ @seed_pid = nil
667
+ end
668
+
669
+ # Spawns a replacement worker for the slot the seed vacated when
670
+ # it was promoted, returning the slot index it filled.
671
+ #
672
+ # @return [Integer, nil] the filled slot index, or nil if none was vacated
673
+ #
674
+ # @rbs () -> Integer?
675
+ def fill_vacated_seed_slot
676
+ index = @seed_vacated_index
677
+ return unless index
678
+
679
+ @seed_vacated_index = nil
680
+ return if @shutdown
681
+
682
+ spawn_worker(index)
683
+ index
684
+ end
685
+
398
686
  # Re-execs the master process with a fresh boot of the same Raptor
399
687
  # invocation, handing the new master its listening sockets so accepted
400
688
  # connections continue to be served across the swap.
@@ -425,11 +713,10 @@ module Raptor
425
713
  exec(@launch_command, *@launch_argv)
426
714
  end
427
715
 
428
- # Runs the full server stack inside a worker process.
429
- #
430
- # Sets up and coordinates the reactor, server, ractor pool, thread pool,
431
- # and stats thread, running until a shutdown signal is received or a
432
- # critical component fails.
716
+ # Runs a worker process's full server stack until a shutdown signal is
717
+ # received or a critical component fails. On `SIGURG` the worker drains
718
+ # and transitions into a seed loop that forks replacement workers on
719
+ # master's request.
433
720
  #
434
721
  # @param index [Integer] slot index for this worker in the stats region
435
722
  # @param phase [Integer] the cluster phase this worker was forked at
@@ -437,12 +724,17 @@ module Raptor
437
724
  #
438
725
  # @rbs (Integer index, Integer phase) -> void
439
726
  def run_worker(index, phase)
727
+ @fork_w.close if @fork_w && !@fork_w.closed?
728
+ @resp_r.close if @resp_r && !@resp_r.closed?
729
+
440
730
  shutdown_requested = false
731
+ promote_to_seed = false
441
732
  trap("INT") { shutdown_requested = true }
442
733
  trap("TERM") { shutdown_requested = true }
443
734
  trap("HUP") { reopen_logs }
444
735
  trap("USR1", "IGNORE")
445
736
  trap("USR2", "IGNORE")
737
+ trap("URG") { promote_to_seed = true } if @fork_r
446
738
 
447
739
  Raptor::CPU.pin(index) if Raptor::CPU.count >= @worker_count
448
740
 
@@ -529,6 +821,8 @@ module Raptor
529
821
  )
530
822
  server_thread = server.run
531
823
 
824
+ @before_worker_boot.each(&:call)
825
+
532
826
  Log.info "Worker #{index} booted"
533
827
 
534
828
  stats_thread = Thread.new do
@@ -547,19 +841,26 @@ module Raptor
547
841
  last_checkin: Process.clock_gettime(Process::CLOCK_REALTIME),
548
842
  booted: true
549
843
  )
550
- break if shutdown_requested
844
+ break if shutdown_requested || promote_to_seed
551
845
 
552
846
  sleep 1
553
847
  end
554
848
  end
555
849
 
556
- until shutdown_requested
850
+ until shutdown_requested || promote_to_seed
557
851
  break unless server_thread.alive? && reactor_thread.alive?
558
852
 
559
853
  sleep 0.5
560
854
  end
561
855
 
562
- server.shutdown
856
+ if promote_to_seed
857
+ @before_refork.each(&:call)
858
+ server.stop_accepting
859
+ else
860
+ @before_worker_shutdown.each(&:call)
861
+ server.shutdown
862
+ end
863
+
563
864
  server_thread.join
564
865
  reactor.shutdown
565
866
  reactor_thread.join
@@ -567,6 +868,44 @@ module Raptor
567
868
  http1.shutdown
568
869
  drain_thread_pool(thread_pool)
569
870
  stats_thread.join
871
+
872
+ run_seed_loop(index) if promote_to_seed
873
+ end
874
+
875
+ # Runs the seed's fork loop, forking a replacement worker for each
876
+ # slot index the master asks for.
877
+ #
878
+ # @param index [Integer] the seed's original slot index
879
+ # @return [void]
880
+ #
881
+ # @rbs (Integer index) -> void
882
+ def run_seed_loop(index)
883
+ Log.info "Worker #{index} promoted to seed"
884
+
885
+ seed_shutdown = false
886
+ trap("INT") { seed_shutdown = true }
887
+ trap("TERM") { seed_shutdown = true }
888
+ trap("URG", "IGNORE")
889
+
890
+ child_pids = []
891
+ trap("CHLD") do
892
+ child_pids.reject! { Process.wait(_1, Process::WNOHANG) rescue true }
893
+ end
894
+
895
+ @resp_w.write([0].pack("L"))
896
+
897
+ until seed_shutdown
898
+ next unless @fork_r.wait_readable(1)
899
+
900
+ bytes = @fork_r.read_nonblock(8, exception: false)
901
+ break unless bytes.is_a?(String) && bytes.bytesize == 8
902
+
903
+ slot_index, child_phase = bytes.unpack("LL")
904
+ pid = fork { run_worker(slot_index, child_phase) }
905
+ child_pids << pid
906
+ @resp_w.write([pid].pack("L")) rescue nil
907
+ end
908
+ rescue Errno::EPIPE, IOError
570
909
  end
571
910
 
572
911
  # Shuts down the worker's application thread pool, force-killing the
data/lib/raptor/http.rb CHANGED
@@ -18,6 +18,7 @@ module Raptor
18
18
  CONTENT_TYPE = "CONTENT_TYPE"
19
19
  HTTP_VERSION = "HTTP_VERSION"
20
20
  REMOTE_ADDR = "REMOTE_ADDR"
21
+ REQUEST_URI = "REQUEST_URI"
21
22
  SERVER_SOFTWARE = "SERVER_SOFTWARE"
22
23
  SERVER_SOFTWARE_VALUE = "Raptor/#{Raptor::VERSION}".freeze
23
24