Class: PumaPlus::RactorWorker

Inherits:
Object
  • Object
show all
Defined in:
lib/puma_plus/ractor_worker.rb

Overview

A worker process whose unit of capacity is a Ractor rather than a thread.

Same contract as Worker: N units of capacity, each dialing the Go server and serving one request at a time. What differs is what a unit is. Threads share one lock, so N threads running CPU-bound Ruby is still one core's worth of throughput -- that is the entire reason this project scales by forking processes, and the reason the controller carries GVL instrumentation to detect when adding a thread would be pointless. Ractors each hold their own lock and genuinely run in parallel, so the actuator the controller wants ("add capacity") becomes available without a fork.

This architecture is an unusually good fit for Ractors, and the reason is what is absent rather than what is present. The hard part of a Ractor web server is work distribution: Ractors cannot share a mutable queue, so cougar (github.com/jhawthorn/cougar) has every Ractor call accept() on one shared TCPServer and lets the kernel do the balancing. puma-plus has no listener and no queue in Ruby at all -- both are in Go -- so each Ractor dials its own unix socket and shares literally nothing with its siblings. There is no shared object to make shareable, and no accept-balancing to get wrong.

Cost model versus forking a worker: a Ractor starts in roughly a millisecond against tens to hundreds for fork-plus-app-boot, and it starts warm -- same heap, same JIT state, no re-require. Fork dead time is exactly what costs the autoscaler its p99 in the step-mix benchmark, so this is the actuator most likely to close that gap.

The remaining constraint is the app, which must be deeply frozen to cross a Ractor boundary. That is a real limitation and it excludes most of the ecosystem today, Rails very much included. See #shareable!.

Known gap for the spike: WS/WebTransport publishing (PumaPlus::WS) keeps module-level state in the main Ractor and is not reachable from inside one, so realtime apps must still use the threaded worker.

Instance Method Summary collapse

Constructor Details

#initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil, logger: $stderr) ⇒ RactorWorker

Returns a new instance of RactorWorker.



47
48
49
50
51
52
53
54
55
# File 'lib/puma_plus/ractor_worker.rb', line 47

def initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil,
               logger: $stderr)
  @socket_path = socket_path
  @app_path = app_path
  @ractors = ractors
  @worker_id = worker_id
  @logger = logger
  @hooks = Hooks.load(config_path, logger: logger)
end

Instance Method Details

#heartbeat_kvObject

One process, so one pid. Go reads its RSS from /proc; a per-Ractor share is not a real quantity, because Ractors share a heap.



136
137
138
# File 'lib/puma_plus/ractor_worker.rb', line 136

def heartbeat_kv
  [["workers", live_count], ["worker_pids", Process.pid.to_s]]
end

#quiesceObject

Nothing to stop replacing: Ractors are never respawned on death here, and Go decides the count.



130
# File 'lib/puma_plus/ractor_worker.rb', line 130

def quiesce = @logger.puts("[puma-plus] quiescing")

#runObject



57
58
59
# File 'lib/puma_plus/ractor_worker.rb', line 57

def run
  run_preloaded(AppLoader.load(@app_path))
end

#run_preloaded(app) ⇒ Object

Serve using an already-loaded app, mirroring Worker#run_preloaded so a shepherd can preload once and hand the same object to either worker kind.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/puma_plus/ractor_worker.rb', line 63

def run_preloaded(app)
  # Ruby still prints an experimental-feature warning on first Ractor use.
  # Scoped to this call rather than set globally, so warnings from anything
  # else stay visible.
  warning_was = Warning[:experimental]
  Warning[:experimental] = false

  app = shareable!(app)

  # The event hook is VM-wide, so one registration covers every Ractor. What
  # it measures changes meaning here: siblings no longer contend for a shared
  # lock, so a rising GVL fraction now indicates contention *within* a
  # Ractor rather than across the process.
  PumaPlus::GVL.start!

  # Each argument crosses a Ractor boundary, so each must be shareable.
  # Integers and the frozen path are; `app` was just checked.
  @app = app
  @path = @socket_path.dup.freeze
  @live = {}
  @mutex = Mutex.new
  @exit_port = Ractor::Port.new
  @next_index = 0
  @running = true

  # In the main Ractor, before any Ractor starts. Hooks cannot run *inside* a
  # Ractor at all -- they are blocks closing over the config file's scope,
  # which is exactly what cannot cross a Ractor boundary -- so there is one
  # boot hook per process here rather than one per unit of capacity. For the
  # usual use, establishing a connection pool, per-process is what you want
  # anyway.
  @hooks.run(:on_worker_boot, @worker_id)

  reaper = Thread.new { reap_loop }
  @ractors.times { spawn_ractor }

  @logger.puts "[puma-plus] worker #{@worker_id} pid=#{Process.pid} " \
               "serving with #{@ractors} ractors"

  # A Ractor cannot be killed from outside, so unlike the threaded worker
  # there is no Thread#kill equivalent to drain with. The graceful path is
  # GOAWAY from Go, which breaks each WorkerThread's read loop from the
  # inside; TERM is the abrupt fallback and does drop in-flight requests.
  trap("TERM") { @running = false }
  trap("INT")  { @running = false }

  # The control connection is what makes autoscaling possible: without it
  # Go can retire Ractors on its own (GOAWAY an idle conn) but has no way to
  # ask for a new one, since only Ruby can call Ractor.new.
  @control = ControlChannel.new(socket_path: @socket_path, logger: @logger).connect!
  @control.run(self)
  reaper.kill
ensure
  @hooks.run(:on_worker_shutdown, @worker_id, fatal: false)
  @control&.close
  Warning[:experimental] = warning_was unless warning_was.nil?
end

#running?Boolean

--- ControlChannel handler protocol ---

Returns:

  • (Boolean)


123
# File 'lib/puma_plus/ractor_worker.rb', line 123

def running? = @running

#set_slots(target) ⇒ Object

SET_SLOTS carries the desired Ractor count.



126
# File 'lib/puma_plus/ractor_worker.rb', line 126

def set_slots(target) = adjust_ractors(target)

#shutdown(_grace_ms) ⇒ Object



132
# File 'lib/puma_plus/ractor_worker.rb', line 132

def shutdown(_grace_ms) = @running = false