Class: HotCell::Limits

Inherits:
Object
  • Object
show all
Defined in:
lib/hot_cell/limits.rb

Overview

What a worker may consume, and what a cell will let an operation ask for. deadline is seconds; memory and file_size are bytes; open_files is a count. Active Support's helpers work — 1280.megabytes is a plain Integer already, and a 30.seconds duration flattens to one on arrival.

There is deliberately no RLIMIT_CPU. The deadline strictly covers it — anything that burns CPU also burns wall clock, and the deadline additionally catches a worker blocked on a wedged subprocess, which trips no CPU limit because a stuck worker consumes no CPU at all. The two numbers are related by a factor nobody can predict, measuring 1.0x at libvips concurrency 1 and about 1.5x at 4, so a CPU limit cannot be derived from a latency budget. And RLIMIT_CPU is cumulative over a process's life, so it stops meaning "per request" the moment a worker serves a second one.

What is given up is real: RLIMIT_CPU was kernel-enforced and would still fire if the supervisor's timer logic were wrong. That is the reason to keep the supervisor's loop boring.

Constant Summary collapse

MEMORY_FLOOR =

Below this a worker does not fail gracefully, it dies before it can answer, as SIGABRT or ENOMEM during boot. Roughly 450MB of any RLIMIT_DATA is Ruby's own: since 3.3 the interpreter reserves a single ~404MB writable anonymous region at boot that it never touches, and RLIMIT_DATA charges all of it. So this is not "how much a bomb may consume" — subtract 450MB before reading it that way.

1024 * 1024**2
RESOURCES =

RLIMIT_DATA rather than RLIMIT_AS. RLIMIT_DATA charges private writable anonymous mappings and ignores PROT_NONE reservations, read-only file mappings, and MAP_SHARED of any kind. Against a real variant whose peak RSS is 45MB, RLIMIT_DATA works from 704MB where RLIMIT_AS needs 1536MB, and RLIMIT_AS fails nondeterministically for a 400MB band below its floor. Do not add RLIMIT_AS as a backstop: any value clearing that band already exceeds the container's own memory limit.

{
  memory:     Process::RLIMIT_DATA,
  file_size:  Process::RLIMIT_FSIZE,
  open_files: Process::RLIMIT_NOFILE,
}.freeze
KEYS =
[ :deadline, *RESOURCES.keys ].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(deadline: nil, memory: nil, file_size: nil, open_files: nil) ⇒ Limits

to_f and to_i, because these travel as JSON and 30.seconds is an ActiveSupport::Duration until it is asked to be a number.



41
42
43
44
45
46
47
48
49
# File 'lib/hot_cell/limits.rb', line 41

def initialize(deadline: nil, memory: nil, file_size: nil, open_files: nil)
  @deadline = deadline&.to_f
  @memory = memory&.to_i
  @file_size = file_size&.to_i
  @open_files = open_files&.to_i

  verify_positive!
  verify_memory_floor!
end

Class Method Details

.memory_enforceable?Boolean

Returns:

  • (Boolean)


104
105
106
# File 'lib/hot_cell/limits.rb', line 104

def memory_enforceable?
  @memory_enforceable
end

.memory_unenforceable!Object



108
109
110
111
112
113
# File 'lib/hot_cell/limits.rb', line 108

def memory_unenforceable!
  return unless @memory_enforceable

  @memory_enforceable = false
  warn "hotcell: RLIMIT_DATA is not settable on #{RUBY_PLATFORM}; the cell's memory limit is not enforced"
end

Instance Method Details

#[](key) ⇒ Object



51
52
53
# File 'lib/hot_cell/limits.rb', line 51

def [](key)
  public_send key
end

#apply(ceiling: self) ⇒ Object

The soft limit narrows to the operation and the hard limit stays at the cell's ceiling. An unprivileged process can raise a soft limit up to its hard limit but can never raise a hard one, so this is what lets a reused worker widen back for an operation with a different budget. Setting both to the operation's value would make the first request the tightest the worker could ever be.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/hot_cell/limits.rb', line 80

def apply(ceiling: self)
  Process.setrlimit Process::RLIMIT_CORE, 0

  RESOURCES.each do |key, resource|
    soft = self[key]
    next if soft.nil?
    next if key == :memory && !self.class.memory_enforceable?

    begin
      Process.setrlimit resource, soft, ceiling[key] || soft
    rescue Errno::EINVAL
      raise unless key == :memory

      self.class.memory_unenforceable!
    end
  end
end

#clamped_to(ceiling) ⇒ Object

An operation cannot exceed its cell's limits, whatever it declares. This is invariant 6, and a clamp that silently stops clamping looks exactly like a clamp, which is why it is tested.



72
73
74
# File 'lib/hot_cell/limits.rb', line 72

def clamped_to(ceiling)
  self.class.new(**KEYS.to_h { |key| [ key, smaller(self[key], ceiling[key]) ] })
end

#declaredObject



59
60
61
# File 'lib/hot_cell/limits.rb', line 59

def declared
  to_h.compact
end

#merge(**values) ⇒ Object

A new Limits with these values over this one's — what a redeclaration means. A key that is not named keeps its value; one that is named to nil is withdrawn, so a redeclaration can also hand a limit back to the cell.



66
67
68
# File 'lib/hot_cell/limits.rb', line 66

def merge(**values)
  self.class.new(**to_h.merge(values))
end

#to_hObject



55
56
57
# File 'lib/hot_cell/limits.rb', line 55

def to_h
  KEYS.to_h { |key| [ key, self[key] ] }
end