Class: SecurityBox::RactorPool

Inherits:
Object
  • Object
show all
Defined in:
lib/security_box/ractor_pool.rb

Overview

Pool of Ractor workers sharing one Engine + compiled Module (stage-3 Q2 "Plan C"): the pool builds a dedicated runtime pair, makes it Ractor-shareable, and N worker Ractors each keep a private Linker. Per eval a worker creates Store + Instance (~0.2ms), applies the request's fuel/epoch/memory limits (safe: the worker owns its store), and invokes.

This is the only supported way to run evaluations in parallel: invoke holds the GVL for the whole guest lifetime (stage-4 Q1), so threads serialize (see Pool).

Protocol (Ruby 3.4+/4.0 Ractor port model):

  • eval builds a request code, token, config and sends it to the worker with the fewest in-flight requests (worker << request);
  • workers run the shared EvalRun core and push worker, result back to the main Ractor (Ractor.main << ...);
  • a main-side collector thread receives from the main Ractor port and routes each result to the Queue of the thread waiting for it (results are matched by request id; this thread must be the only main-port receiver in the process);
  • every request terminates (epoch + fuel), and a pop deadline converts a dead worker into a :sandbox_error result instead of a hang.

Trade-offs (measured, stage-3 Q2 / stage-4): one module deserialize per pool (~0.5s via the disk cache); each worker's eval still pays the ~240ms guest boot. Ratios at 2/4 Ractors on 6 cores: 0.56 / 0.28 wall vs serial, RSS ~111MB with the shared runtime.

Usage:

pool = SecurityBox::RactorPool.new(:lean, size: 4)
pool.eval("1 + 1")     # => Result
pool.shutdown

Constant Summary collapse

STOP =
:security_box_stop
POP_SLACK_MS =
10_000

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(profile = nil, size: 4, **options) ⇒ RactorPool

Returns a new instance of RactorPool.

Raises:

  • (ArgumentError)


43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/security_box/ractor_pool.rb', line 43

def initialize(profile = nil, size: 4, **options)
  @size = Integer(size)
  raise ArgumentError, "size must be >= 1" if @size < 1

  @config = resolve_config(profile, options)
  raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless @config.rpcs.empty?

  @mutex = Mutex.new
  @pending = {} # request id => Queue
  @in_flight = Array.new(@size, 0)
  @closed = false
  @next_id = 0
  @metrics = { evals: 0, total_ms: 0.0, timeouts: 0 }

  engine, module_ = Runtime.build_shareable(
    image_path: @config.image_path,
    epoch_interval_ms: @config.epoch_interval_ms
  )
  @workers = Array.new(@size) do |index|
    build_worker(engine, module_, index)
  end
  start_collector_thread
end

Instance Attribute Details

#size ⇒ Object (readonly)

Returns the value of attribute size.



41
42
43
# File 'lib/security_box/ractor_pool.rb', line 41

def size
  @size
end

Instance Method Details

#closed? ⇒ Boolean

Returns:

  • (Boolean)


123
124
125
# File 'lib/security_box/ractor_pool.rb', line 123

def closed?
  @mutex.synchronize { @closed }
end

#eval(code, **overrides) ⇒ Object

Runs code on one of the workers and blocks until its Result arrives. Per-call overrides follow Sandbox#eval (same Configuration#with rules).

RPC handlers are not supported here (v1): they are host Procs that cannot cross a Ractor boundary. A configuration with rpcs raises InvalidConfiguration at construction.

Raises:



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
# File 'lib/security_box/ractor_pool.rb', line 73

def eval(code, **overrides)
  raise PoolClosed, "pool is closed" if closed?

  config = overrides.empty? ? @config : @config.with(**overrides)
  raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless config.rpcs.empty?

  request = {
    id: next_id,
    code: code,
    token: SecureRandom.hex(16),
    config: config
  }
  queue = Queue.new
  worker_index = @mutex.synchronize do
    @pending[request[:id]] = queue
    index = @in_flight.each_with_index.min_by { |count, _| count }.last
    @in_flight[index] += 1
    index
  end

  begin
    @workers[worker_index] << request
    deadline_ms = config.timeout_ms + POP_SLACK_MS
    result = queue.pop(timeout: deadline_ms / 1000.0)
    raise ThreadError, "no result" if result.nil? # Queue#pop timeout
    record(result)
    result
  rescue ThreadError, Ractor::ClosedError
    # Worker died, stalled beyond the deadline, or was shut down mid-flight:
    # report instead of hang or leak the error.
    @mutex.synchronize { @metrics[:timeouts] += 1 }
    Result.worker_unavailable(worker_index)
  ensure
    @mutex.synchronize do
      @in_flight[worker_index] -= 1 if @in_flight[worker_index] > 0
      @pending.delete(request[:id])
    end
  end
end

#metrics ⇒ Object

Snapshot of the counters: evals:, total_ms:, avg_ms:, timeouts:.



114
115
116
117
118
119
120
121
# File 'lib/security_box/ractor_pool.rb', line 114

def metrics
  @mutex.synchronize do
    evals = @metrics[:evals]
    { size: @size, evals: evals, total_ms: @metrics[:total_ms].round(2),
      avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2),
      timeouts: @metrics[:timeouts] }
  end
end

#shutdown ⇒ Object

Asks all workers to stop. Evals already in flight finish; new evals raise PoolClosed.



129
130
131
132
# File 'lib/security_box/ractor_pool.rb', line 129

def shutdown
  @mutex.synchronize { @closed = true }
  @workers.each { |worker| worker << STOP }
end