Runner v0.2¶
mapi-runner executes one run in a sandbox. It implements the side of the execution in the
run protocol. Two processes are part of a run:
- The supervisor is trusted. It has the HMAC key of the run and the signed URLs. It is the only process that writes to the stdout of the execution.
- The worker is not trusted. It runs the platform harness (
mapi_sdk) together with plugin code. It does not get the key or a URL that can write.
sequenceDiagram
participant X as Executor
participant S as Supervisor
participant G as Object store
participant W as Worker
X->>S: start (env: MAPI_RUN_ID, MAPI_NONCE, MAPI_BOOTSTRAP_URL)
S->>G: GET bootstrap/{nonce}, then DELETE that generation
S->>W: spawn (scrubbed env, uid 10001), start message over a socketpair
W->>G: range-read inputs (signed GET)
W-->>S: state / progress / diagnostic / metric / checkpoint messages
W-->>S: metrics / params / tags / user_log messages (v0.11)
W-->>S: media messages, files in {scratch}/media/ (v0.14)
W-->>S: stdout + stderr (pipes, captured as log lines)
S-->>X: stdout: {"mapi_event": …} (HMAC + seq per line)
S->>G: POST outputs (policy), PUT manifest.json
S-->>X: "complete" event, exit 0
Start a run¶
MAPI_RUN_ID=run_01… MAPI_NONCE=<64 hex> \
MAPI_BOOTSTRAP_URL='http://127.0.0.1:4443/storage/v1/b/mapi-local-bootstrap/o/{nonce}' \
MAPI_EXEC_TOKEN=<local shim exec token> \
mapi-runner
All the configuration of the run comes from the environment. The command-line options tell only about the image. They never contain secrets:
| Option | Default | Meaning |
|---|---|---|
--worker-python PATH |
this interpreter, or MAPI_WORKER_PYTHON |
The interpreter of the worker environment (the site-packages of the base image and all overlays) |
--worker-registry MODULE:CALLABLE |
entry points | The factory of the plugin registry for the worker |
--scratch-dir DIR |
$TMPDIR |
The parent directory of the scratch space of the run |
--keep-scratch |
off | Keep the scratch directory after the run, for debug |
--term-grace SECONDS |
5 | The time that the worker gets to stop after SIGTERM. After this time, the supervisor kills the worker. |
--deadline-reserve SECONDS |
15 | The time before the deadline for the checkpoint flush and a partial manifest |
--base-image NAME |
MAPI_BASE_IMAGE |
Recorded in the output manifest |
What the supervisor guarantees¶
- The supervisor deletes the bootstrap object before code that is not trusted runs. The
supervisor reads the object. Then it deletes exactly the generation that it read. If the
delete fails, the run stops with exit code
2. - Only signed events go to stdout. The supervisor keeps a private copy of the stdout
descriptor for its event writer. It points fd 1 at stderr. All text that the worker prints
goes into the
lineof alogevent. This is also true for text that looks like an event. Each line hasseqand an HMAC with the key of the run. -
The worker gets a scrubbed environment. Only
WORKER_ENV_ALLOWLISTgoes through. Thus noMAPI_*variable gets to the worker. The worker starts withpython -I -uin a new session, withTMPDIRset to its scratch directory. If the supervisor runs as root (Cloud Run, docker), these rules also apply:- The worker runs as uid/gid
10001with no supplementary groups. - The worker has
no_new_privsand no core dumps. - The supervisor marks itself non-dumpable.
In all other conditions (macOS, a non-root shell), the run continues in dev mode. The supervisor reports a
DEV_MODE_ISOLATIONwarning event. Dev mode is not a security boundary. An executor that promises the split setsMAPI_REQUIRE_ISOLATION=1(the docker executor does this). Then the supervisor does not go into dev mode. It fails the run withSANDBOX_VIOLATION. - The supervisor validates worker messages. Each message is a JSON frame with a length prefix and a maximum size of 256 KiB. It must agree with its Pydantic model. These are aSANDBOX_VIOLATION: a malformed frame, an unknown type, or a message after the final result. - Logs have caps. The supervisor removes ANSI escapes, control characters and bidi overrides. It cuts lines atlimits.log_line_bytes(4 KB) and flags themtruncated. More thanlimits.log_total_bytes(20 MB) of worker output isSANDBOX_VIOLATION (log_flood). - The supervisor cleans worker text. It removes the same characters from all other strings that the worker sends. These strings are state and progress messages, warnings, diagnostics, metric labels, and error and issue messages. Line breaks become spaces, except inuntrusted_text. - The supervisor coalesces progress. It sends a maximum of oneprogressevent in eachlimits.progress_interval_s(2 s). The newest value wins. - Tracking data has budgets (v0.11). The supervisor counts eachmetrics,params,tagsanduser_logmessage againstlimits.tracking(TrackingLimits). It does not keep points or items that are more than a budget, and it counts them. At the end of the run, it sends oneTRACKING_BUDGETwarning. More thantracking_bytes(64 MiB) of tracking messages isSANDBOX_VIOLATION(tracking flood). The supervisor cleans the strings of each message. It divides each payload withsplit_for_events, thus no event is larger thanlimits.max_event_bytes. Tracking messages do not go through the progress coalescer. - The supervisor uploads outputs only from regular files. It opens each file withO_NOFOLLOW, relative to directories that it opened before. A symlink, FIFO or device in the outputs tree is a violation. The reserved namesmanifest.jsonandcheckpoints/are also violations. The object caps and byte caps fromlimitsapply. The supervisor selects the content type of each object from its extension. - The manifest tells exactly which bytes the supervisor sent. The supervisor calculates CRC32C and MD5 while it streams each file through the POST policy. It signs theOutputManifestwith the key of the run and PUTs it last. Thecompleteevent comes after the manifest. - The supervisor enforces the scratch budget. It measures the scratch directory each 2 s. The worker refuses all inputs that are larger than the remaining space. If the run uses more thanlimits.scratch_bytes, the result isRESOURCE_EXHAUSTED. - The worker runs as uid/gid
Tracking in the worker v0.11¶
The worker gives a tracker to the model code (WorkerContext.tracker, a WorkerTracker).
The harness puts it on FitContext.tracker, ScoreContext.tracker and, for workflow node
runs, on NodeContext.tracker. The tracker has these properties:
- It is thread-safe. A sampler callback on a different thread can log points.
- It collects points for each series. A background thread sends them each
flush_interval_s(1 s). When 1000 points wait, the tracker sends them immediately. - It does the same budget checks as the supervisor, thus a correct worker never gets to the limits of the supervisor.
- It sends params and tags with the next flush. It sends a
userlog line immediately. chains()keeps the newest per-chain progress. The nextprogressmessage has it inchains.
Before the worker sends its result or error message, it closes the tracker. Thus the last
points are sent. When the tracker did not keep some points, it sends one TRACKING_BUDGET
warning. The end-of-run metrics (metric events, for example the holdout rmse) are also
kept as scalar series. The guide Track metrics, params and tags shows
the API that model code uses.
Media, images and the GPU class¶
Media v0.14. ctx.log_image, ctx.log_figure and
ctx.log_table write each item to {scratch}/media/ and send a media message. The
supervisor reads the file, does a check of it, and uploads it through the media POST
policy of the attempt. Then it sends a media event. The supervisor counts the items and the
bytes of the attempt against media_items and media_bytes. After the budget, it drops the
items and sends one MEDIA_BUDGET warning. Run protocol: Media
gives each step and each warning.
Images v0.14. mapi-runner depends on Pillow. Only the
worker decodes images: the image_folder ingest, the collection ingest (v0.15) and the
decoded samples of a collection fit. The supervisor and the control plane read only the
headers. The collection ingest reads the parts in ranges and makes sure that each image has
the SHA-256 of its name (Run protocol: Image collections).
A fit on a collection decodes a sample of a maximum of 500 images for its views and its
scorecard. If that fails, the worker sends VIEW_SAMPLE_FAILED, and the fit continues.
The GPU class v0.15. When a run has the resource class
GPU and the supervisor finds no NVIDIA device (/dev/nvidia0 or /dev/nvidiactl), it
sends one GPU_UNAVAILABLE warning. The run continues on the CPU. The supervisor gives the
worker the JAX and XLA variables of the worker-environment allow-list (JAX_PLATFORMS,
XLA_FLAGS, XLA_PYTHON_CLIENT_PREALLOCATE, XLA_PYTHON_CLIENT_MEM_FRACTION and
CUDA_VISIBLE_DEVICES).
SIGTERM, the deadline and checkpoints¶
The supervisor does these steps on SIGTERM, or when the time gets to the deadline minus the reserve:
- It sends a
RUN_TERMINATINGwarning event and sends SIGTERM to the session of the worker. - It waits for the worker to stop. The worker cooperates when it cancels. Samplers do a
check for cancellation after some draws. The segmented sampler of PyMC writes a final
checkpoint before it stops. After
--term-grace, the supervisor kills the worker. - It waits for the checkpoint uploads that did not finish.
- If checkpoints exist, it uploads a manifest with
status: partialthat lists the checkpoint objects. Then it exits with16 PARTIAL. If no checkpoints exist, it exits with143(SIGTERM) or14(deadline).
The worker writes checkpoints to {scratch}/checkpoints/ as two flat files for each fit
key: ckpt-<key>.draws.zarr.zip and ckpt-<key>.meta.json. The supervisor uploads the two
files with the checkpoints POST policy. It sends a checkpoint event for each file. A
resumed attempt gets the same names in RunSpec.resume: the resume names are equal to the
object names relative to the checkpoint prefix. The runner downloads them before the fit
starts, and the engine continues from them.
From v0.15, a network that trains in epochs saves its checkpoints as files every
fit.checkpoint.every_epochs epochs: ckpt-<key>.weights.safetensors,
ckpt-<key>.opt.safetensors and ckpt-<key>.meta.json
(Checkpoints of networks). If the meta.json is
larger than 16 MiB, the worker does not save the checkpoint and sends CHECKPOINT_SKIPPED.
Exit codes¶
| Exit | When |
|---|---|
0 |
The supervisor uploaded the outputs and the manifest, and sent the complete event |
2 |
The environment is bad, the bootstrap is missing or not valid, or the supervisor cannot use outputs. (There are no events if the supervisor could not read the RunSpec.) |
10 |
The worker reported a user, spec or data error, crashed, or exited without a result |
11 |
Out of memory (MemoryError, or SIGKILL killed the worker), or the run used more than the scratch budget |
12 |
The uploads or downloads of the supervisor failed after retries |
13 |
The size, CRC32C or MD5 of an input was not correct, or a bundle did not agree with its manifest |
14 |
The run got to the deadline without a checkpoint |
15 |
Sandbox violation: log flood, malformed messages, unsafe outputs, oversized events or output caps |
16 |
Partial: the worker flushed a checkpoint after SIGTERM or at the deadline |
143 |
SIGTERM with no checkpoint to flush |
The supervisor always reports a failure in an error event. The supervisor writes the
message of this event. Text from the worker or from a plugin goes in untrusted_text. The
supervisor also sends typed issues from the worker as diagnostic events, for example
MISSING_FUTURE_COVARIATE.
The supervisor accepts only three failure classes from the worker: resource_exhausted,
integrity and sandbox. If the worker reports INFRA_ERROR, the run fails as user. An
example is a failed input download. The reason is that the infra exit makes the platform
retry the full run automatically. Thus only failures that the supervisor sees itself can use
infra. All other failures that the worker reports become user.
What RunSpec.spec holds, per kind¶
The dispatcher writes these documents, and the runner reads them. The two sides import the
models and the names of the inputs and outputs from mapi_spec.jobs. Refer to the
run protocol. The runner reads inputs with
HTTP range requests. Each dataset is one object: *.zarr.zip, or data.tree.zip for a
tree dataset (v0.8). A tree input can come with a tree_schema
input (the tree_schema.json of its version). The runner selects the nodes of
data.nodes and merges them into the flat input of the pipeline. A tree-input model also
gets the whole tree. The raw schema of a plain dataset comes from one of these sources, in
this sequence:
- the
schemainput, if there is one - the zarr root attribute
mapi_schema(mapi_spec.jobs.DATASET_SCHEMA_ATTR). Ingest writes this attribute, and uploads can contain it. - a schema that the runner infers.
| Kind | spec |
Inputs | Outputs (relative to the attempt prefix) |
|---|---|---|---|
ingest |
IngestMapping, or IngestJob ({"mapping": …}, {"preset": "mff", "mff": {…}} or, from v0.8, {"assembly": …}, and also dataset and source, which are only for information) |
upload, or upload.0, upload.1, … (one for each source of an assembly) |
data.zarr.zip, schema.json, summary.json (DataSummary), profile.json (mapi.profile/v1, with the sha256 and size_bytes of the data), preview.json (mapi.preview/v1). A tree version (v0.8) writes data.tree.zip and tree_schema.json (TreeSchema) in place of data.zarr.zip. Its schema.json is the schema of the root node, summary.json adds a summary for each node under nodes, and the explorer views of each node are under ui/explore/nodes/<slug>/ with an index |
inspect |
InspectJob (v0.8: format, and caps for arrays, HDF5 objects, dims and attributes) |
upload |
inspection.json (UploadInspection). The run reads only metadata, after the HDF5 guard |
describe |
DescribeJob ({"image_digest": …?}) |
none | catalog.json (Catalog), describe.json (catalog_digest, versions) |
validate |
a ModelSpec |
dataset, optional schema, side.<name> |
validation.json (ValidateResponse with the stages that a dry run resolved) |
fit |
a ModelSpec |
dataset, optional schema, side.<name> |
bundle.zarr.zip, bundle.manifest.json, diagnostics.json, plots.json, metrics.json (MetricsReport. v0.9: elpd has the elpd_loo/elpd_waic estimates with their pointwise values), coords.json ({stage: {dim: CoordSummary}}), scorecard.json (Scorecard, v0.4), evaluation/…, ui/thin.zarr.zip, ui/summaries.json, ui/tree_index.json (mapi.tree-index/v1), ui/preprocess.json (mapi.preprocess/v1), ui/grids/*.arrow, ui/grids/response_curves.json (mapi.response-grid/v1, v0.4), ui/plotdata/*.arrow |
score |
a ScoreRequest, or {"request": …, "plots": […], "max_draws": n} |
bundle, optional bundle.manifest, dataset (payload), scenario, side.<name>, history (v0.4) |
score.zarr.zip, result.json, summary.json (ScoreSummary: the summary as tables, at most 100 000 cells. v0.9: a circular output has period), ui/summary.arrow, plots.json. Forecasts add ui/guided/forecast.json (mapi.forecast/v1). Counterfactuals add ui/guided/counterfactual.json (mapi.counterfactual/v1) (v0.4). Predict, forecast and evaluate add ui/fields.json (FieldsView) for a model with grid outputs |
evaluate |
{"request": …?, "metrics": […]?} |
bundle, dataset, side.<name> |
the score outputs and also metrics.json |
derive |
{"names": […], "draws": n?, "data": "dataset://…"?} |
bundle, dataset, side.<name> |
derived.zarr.zip, ui/grids/<name>.arrow, result.json |
render |
{"plots": […]?, "format": "png" \| "svg", "scale": 2} |
plots (a plots.json) |
renders/<plot id>.<format> |
translate_priors |
PriorTranslationJob ({"spec": …, "business_priors": …, "lift_tests": […], "draws": n, "seed": s}, v0.4) |
dataset, optional schema, side.<name> |
priors.json (PriorTranslationResult), plots.json (mmm.prior_beliefs, core.prior_predictive) |
At the top level of the attempt prefix, manifest.json is the OutputManifest of the
runner. Thus the runner uploads the manifest of the SDK bundle as bundle.manifest.json. The
evaluation sub-bundle keeps the SDK layout (evaluation/manifest.json).
In plots.json, the runner moves inline Arrow datasets that are larger than 64 KiB to
ui/plotdata/<plot>.<dataset>.arrow. The plot refers to them as data_ref, with their size
and CRC32C.
The Arrow views (ui/summary.arrow, ui/grids/*.arrow) are tidy tables from
mapi_sdk.wire.to_arrow_table. From v0.9, the schema metadata mapi.dims gives the names of
the dim columns as a JSON list. Thus the web app keeps a numeric dim, for example a grid
position in cm, as a key column and not as a value.
A score run picks its default plots from the shape of the outputs, when the job does not
give plots:
| Output (manifest declaration, result dims) | Default plot |
|---|---|
A grid output (y and x in the result) |
sci.field_map, with var, y, x and the frame dim (when it is in the result) as options. The frame dim is the slider. |
A grid output with a component of size 2 |
sci.vector_field, with the same options |
An output on the areas of ring_x/ring_y (v0.10) |
sci.area_map (var). The polygons come from the bundle. |
Probabilities over a dim with the name state or regime (v0.10): each cell has a sum of 1 |
core.state_sequence (var, state_dim) |
| Evaluate: a binary output (observed values 0/1, predictions from 0 to 1) (v0.10) | core.calibration, core.roc_pr, core.confusion (var, observed_var) |
| An output that is not a grid, over time, with at most one other dim of size more than 1 | core.ppc_fan (var), for predict and forecast |
Each mode except counterfactual starts from the plots of before (DEFAULT_PLOTS). A
counterfactual keeps core.counterfactual. A model with grid outputs or area outputs gets the
fan only when it also has an output over time. A fan of a map adds the values of all cells.
Evaluate draws core.fit_vs_actual. It adds core.residuals_over_time only when an output is
over time. A binary output replaces the two plots with the three classifier plots
(BINARY_EVALUATE_PLOTS). There is at most one plot for each recipe, so each recipe draws one
output. The map draws the first grid output with a frame, else the first grid output. The other
recipes draw the first areal, state or binary output in the sequence of the manifest.
A state output must have a dim with the name state or regime (not only a name that contains
the word, such as us_state). Its values must also be probabilities over that dim. Thus a panel
output such as sales(time, state) for the states of a country does not get the state plot.
The map draws from /predictions, so the runner keeps the full predictions of a grid model until
the plots are done. From v0.10, the runner also keeps them for a model with area outputs and for
each evaluate run. Thus the evaluate plots get /predictions and /observed. score.zarr.zip
does not keep the two groups. The summary.json table of an output gives plots (v0.10): the
ids of the rendered plots that draw it.
A fit run adds core.loss_curve to the plots of a fit that has a fit_history group (an ADVI
fit, v0.10).
The output variables of a grid carry the attr grid (the GridDecl as JSON, with the
dims renamed to the names of the result). summary.json gives dims and time_dim for each
table, and orders the tables by size.
scorecard.json does the data checks on the raw dataset. The check data.missing (v0.9)
does not count these variables:
- the variables with the role
constant,indexormaskin the raw schema or in the schema of the model stage - the canonical variables that the model lists in
CustomModel.structural_missing. The runner gets them from the plugin registry. If it cannot find the plugin, the list is empty.
The message of the check names the variables that it does not count.
A workflow_node run gets a tree dataset input as its root node, and keeps the full tree
(v0.9). Thus the nodes parameter of a model node can select other nodes of the tree.
MFF preset¶
The preset reads a long table with the columns Period, VariableName and VariableValue.
Period becomes the time dim (date, policy extend, inferred frequency). Each key column
(Geography, Product, Campaign, Outlet, Creative) becomes a categorical dim only if
it has more than one distinct value. Each VariableName becomes a variable. You can override
roles, units and column names in mff.
Not implemented yet¶
optimizeruns. They fail withCAPABILITY_UNSUPPORTED. The runner reports this as exit10.renderneedsvl-convert-python(Vega-Lite) orkaleido(Plotly) in the image. If the image does not have them, the run fails with a clear message. The runner does not render plots withdata_refdatasets.
HDF5 and NetCDF-4 v0.8¶
h5py and h5netcdf are explicit dependencies of mapi-runner. Thus each runner image can
read NetCDF-4 and HDF5 uploads. The runner reads NetCDF-3 files with SciPy.
Before xarray or h5py opens an HDF5 file, the HDF5 guard (mapi_runner.worker.hdf5_guard)
walks the file with the low-level API of h5py and refuses unsafe files with
INGEST_UNSAFE_FILE. The supervisor sets HDF5_PLUGIN_PATH to an empty directory and
HDF5_USE_FILE_LOCKING=FALSE in the environment of the worker. Thus libhdf5 cannot load a
filter plugin. Refer to the HDF5 guard.