Operation API¶
This page describes HotCell::Operation, the class that you subclass to write work that runs in a cell,
and the Input and Output objects that perform receives. For the application side of the call, see
Client API.
The code lives in hotcell-server/lib/hot_cell/operation.rb
and hotcell-core/lib/hot_cell/descriptors.rb.
Example¶
The client class in the application and the operation class in the cell share a routing name. The
signature of perform (inputs, outputs, payload) is the contract for what crosses the socket. Descriptors
travel as they are, without copies, and the payload travels as one JSON object.
In the application:
class TransformImage < HotCell::Client
hotcell "images" # the cell that serves this call, by registered name
operation "images.transform" # the routing name an operation must answer to
end
# source and destination are Files the app already opened. This is a blocking call that waits for
# a response from the cell.
TransformImage.perform_in_hotcell source, destination,
format: "png",
operations: { resize_to_limit: [ 800, 600 ] }
In the cell:
require "active_support"
require "active_support/core_ext/numeric" # for 30.seconds and 1280.megabytes
class TransformImageOperation < HotCell::Operation
operation "images.transform" # the same routing name
# Ceilings for one request enforced by rlimits and the supervisor's clock,
# clamped to the cell's own limits.
limits deadline: 30.seconds, memory: 1280.megabytes, file_size: 48.megabytes
before_fork { require "my_image_processor" } # once, in the supervisor
before_worker_boot { MyImageProcessor.concurrency_set 4 } # in forked worker, before it serves a request
# The descriptors the caller passed, and the payload as keyword arguments. A missing or undeclared
# key raises, so the signature is the schema. Declare **payload instead to take the Hash whole.
def perform(inputs, outputs, format:, operations: {})
source, = inputs
destination, = outputs
# fd_path reads the caller's file in place, with no copy onto scratch, so an input of any size
# costs nothing against file_size. Reach for source.path only when a tool needs a distinct on-disk
# copy; that stages the bytes, and the kernel charges the write.
MyImageProcessor.source(source.fd_path)
.apply(format:, operations)
.write_to(destination.fd_path)
# The result: one JSON object, which the caller receives as perform_in_hotcell's return value (in
# addition to the destination file descriptor)
{ format: format, bytes: File.size(destination.fd_path) }
end
end
A cell serves an operation when one of its operation files requires the operation's file. See Cell settings.
Class methods¶
operation(name = nil)¶
Sets the routing name, the name that the operation answers to on the wire. With no argument, returns the routing name.
If you don't call operation, the name derives from the class path: each namespace and the class name in
snake case, joined with dots, with an Operation suffix removed. For example,
Thumbnails::ExtractTextOperation answers to thumbnails.extract_text. An anonymous class must set its
name explicitly.
A subclass doesn't inherit its parent's routing name. It derives its own from its class path unless it declares one, so it never answers to the same name as its parent.
limits(**values)¶
Declares this operation's ceilings for one request. The keys are the four request limits: deadline in
seconds, and memory, file_size, and open_files. See
Cell settings for what each one enforces. Active Support helpers work:
1280.megabytes is an Integer, and 30.seconds becomes a number on arrival. With no arguments, returns
the effective HotCell::Limits.
The cell clamps every value to its own limit for the same key, so an operation can narrow a limit and never widen it. The effective value is the smaller of the two.
limits accumulates. Naming one limit changes that limit and keeps the rest of this class's declaration,
or of the nearest ancestor's declaration when this class has none yet. Naming a limit as nil withdraws
it, which gives that limit back to the cell's value. See
Change a shipped operation's limits.
before_fork { ... }¶
Registers a block that runs once, in the supervisor, at boot. Use it to require and configure libraries.
Caution: A before_fork block must never evaluate an image. libvips can't survive fork after it
evaluates an image: every worker forked after that deadlocks. See
experiment 1.
Every megabyte that the supervisor holds is partly copied by every worker, so require only what this cell's own operations need. See Cell overhead.
before_worker_boot { ... }¶
Registers a block that runs in the worker after the fork and before the worker serves a request. Use it
to size a library, for example Vips.concurrency_set. Hot Cell doesn't size libraries for you: size them
against the cell's own cpus and concurrency.
Configure libraries here rather than in before_fork, because library configuration is global. Two
operations that configured the same library in the supervisor would disagree, and the last one registered
would win.
Above max_requests_per_worker: 1, one worker can serve operation A, then B, then A. The worker runs these
blocks again whenever the operation changes, so write them as re-entrant setters, not as one-time
initialization.
Hooks from a superclass run before the hooks of its subclasses. This applies to before_fork and
before_worker_boot.
unreadable(*classes)¶
Declares the library exception classes that mean "the input can't be decoded", rather than "the operation
broke". When perform raises one of them, the cell answers unreadable, which is
permanent. HotCell::UnreadableInput is always included. A subclass adds to the classes that
its ancestors declared.
abstract_operation¶
Marks a class that exists to be inherited from, not to be dispatched to.
Every subclass of HotCell::Operation registers itself. Without abstract_operation, an intermediate
class that holds shared setup would appear in describe, accept requests, and answer failed from a
perform that raises NotImplementedError. A subclass of an abstract operation is concrete unless it
also calls abstract_operation.
Instance methods¶
perform(inputs, outputs, **payload)¶
Does the work. Hot Cell calls perform on a new instance for each request, so nothing in an instance
variable survives into the next request that a reused worker serves.
inputsis anArrayofInputobjects, andoutputsis anArrayofOutputobjects, in the order that the caller passed them.- The payload arrives as keyword arguments. Declare the keys that you accept, for example
format:, operations: {}, and Ruby validates them on arrival. To take the payload as aHash, declare**payload. If the operation takes no options, declare neither. - A missing or undeclared key raises inside
perform, and the cell answersfailed. - The return value is the result: a
Hashthat the caller receives as the return value ofperform_in_hotcell. It travels as JSON.
run_tool(*command, env: {}, capture: 64 * 1024, pass: [])¶
Runs a tool in a subprocess and returns a ToolResult with the following members:
status: theProcess::Status.out: the captured standard output.err: the captured standard error.ok?: whetherstatusis a success.
The arguments are as follows:
| Argument | Description |
|---|---|
command |
The program and its arguments, for example "mutool", "draw", .... |
env |
Environment variables to add or override. |
capture |
The maximum number of bytes kept from each of standard output and standard error. run_tool reads both streams to the end and discards everything past this limit as it arrives, so a noisy tool can't exhaust the worker's memory. |
pass |
Descriptors to hand to the tool at their own descriptor numbers. The tool reaches each one at its fd_path, and no byte is copied onto scratch. |
The tool runs with unsetenv_others: true and a fully written environment, never a filtered copy of the
worker's environment. This is invariant 9. The environment contains HOME,
TMPDIR, and PATH from the worker, LANG and LC_ALL set to C.UTF-8, and OMP_NUM_THREADS and
OMP_THREAD_LIMIT from the cell's environment, plus env. See
Bound the OpenMP thread pools.
A tool's output is influenced by the attacker. Parsing it brings those bytes back into the worker.
Inputs and outputs¶
Input and Output wrap the descriptors that the caller passed. Both sides verify each descriptor when
they wrap it:
- An input must be read-only, and an output must be write-only. An access mode is fixed at open and can't be narrowed afterward, so a cell can only decline a descriptor with the wrong mode.
- A descriptor must refer to a regular file. A pipe as an output can deadlock.
- A descriptor must not be open with
O_APPEND, which would append the conversion to what the file already holds.
A failed check raises HotCell::AccessModeError.
fd_path¶
Returns a path that reads or writes the descriptor in place, with no copy onto scratch: /dev/fd/N on
Linux. The worker can open it, and so can a tool that received the descriptor through run_tool's
pass:.
Prefer fd_path. Reading in place costs nothing against file_size, so an operation can read a
multi-gigabyte input under a small file_size. Each open of /dev/fd/N on Linux is a new file
description at offset zero.
On macOS, fd_path returns the file's own name, because opening /dev/fd/N there shares the worker's
offset. macOS is a development platform only.
Input#path¶
Copies the input onto the worker's scratch the first time that you call it, and returns the file's name. Use it only when a tool needs a separate file on disk.
The copy is a write, so file_size bounds it. An input larger than the operation's file_size fails here
as killed with cause fsize, which is permanent. The shipped Active Storage operations all read
fd_path for this reason.
Output#path(extension: nil)¶
Names the file on scratch that the operation writes, the first time that you call it. When perform
returns, the worker copies that file back through the descriptor and flushes it before it reports success.
With extension:, returns a sibling name with that suffix, for a tool that appends its own extension,
such as pdftoppm. Call adopt(staged) to rename the sibling onto path so that the worker ships it.
An operation can instead write directly through the descriptor at fd_path. Then nothing is copied, and
the worker only flushes and measures the file. If the operation fails partway through a direct write, the
caller's file holds partial bytes, where a staged output leaves it empty. In either form, if the outputs
hold zero bytes in total after a successful perform, the client reports unavailable.
staged?¶
Returns whether the descriptor has a file on the worker's scratch.
Change a shipped operation's limits¶
To give a shipped operation a different budget without editing the gem, redeclare the one limit after the operation loads, from an operations file:
# hotcell/operations/zz_limits.rb
require "active_storage/hot_cell/server/transformers/image/vips"
ActiveStorage::HotCell::Server::Transformers::Image::Vips.limits file_size: 128 * 1024**2
The transformer's deadline, memory, and open_files don't change.
Keep the following in mind:
- Require the operation first. The class must exist before you can redeclare its limits.
config.rbloads before any operations file, and operations files load in sorted order. Without therequire, the redeclaration must be in an operations file that sorts last, hence thezz_prefix. With therequire, the same lines work from any operations file or fromconfig.rb. Prefer an operations file anyway, so that every declaration about operations is in one place. - A subclass copies its parent's limits when it declares. A subclass that declares
limits deadline: 5takes every other value from its parent, so a narrowing subclass writes only the number that it narrows. It takes them once:limitsresolves to the first ancestor that declared any, and after a class stores its own, it stops looking up. Redeclaring the parent afterward doesn't reach a child that already declared, so redeclare the child too. A subclass that never declares follows its parent. - The cell's limits are still the ceiling. The cell clamps a redeclaration the same way that it clamps
the original. To get 128MB, the cell's own
file_sizemust be at least 128MB. A cell at 64MB gives that redeclaration 64MB.
The shipped operations' limits are in Active Storage operations.