Class: HotCell::Limits
- Inherits:
-
Object
- Object
- HotCell::Limits
- 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
- #[](key) ⇒ Object
-
#apply(ceiling: self) ⇒ Object
The soft limit narrows to the operation and the hard limit stays at the cell's ceiling.
-
#clamped_to(ceiling) ⇒ Object
An operation cannot exceed its cell's limits, whatever it declares.
- #declared ⇒ Object
-
#initialize(deadline: nil, memory: nil, file_size: nil, open_files: nil) ⇒ Limits
constructor
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.
-
#merge(**values) ⇒ Object
A new Limits with these values over this one's — what a redeclaration means.
- #to_h ⇒ Object
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
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 |
#declared ⇒ Object
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_h ⇒ Object
55 56 57 |
# File 'lib/hot_cell/limits.rb', line 55 def to_h KEYS.to_h { |key| [ key, self[key] ] } end |