morca · API

orca.ec2

Runs on EC2: a head instance and worker instances per run, started and ended by the scheduler (DESIGN.md, phase 5, PR 5).

With ORCA_RUNNER=ec2 the scheduler starts a run's start s as:

  • a head instance (--head-type at registration, else ORCA_EC2_HEAD_TYPE, on-demand): the dista head, for a pipeline with tasks, and the driver, <binary> resume <run>, beside it on localhost;
  • workers worker instances (--worker-type, else ORCA_EC2_WORKER_TYPE; Spot with --spot), for a pipeline with tasks, started straight after with DISTA_HEAD at the head's private address.

Each is tagged orca.run, orca.start, orca.pipeline and orca.role, told to terminate when it shuts down, and started from user data that runs the pipeline's image (ORCA_EC2_MODE=docker, the default: docker run --network host, after ORCA_EC2_PREAMBLE, such as an ECR login) or the binary itself (direct, for the tests' stand-in EC2), then shuts down (ORCA_EC2_SHUTDOWN). The head keeps the driver's output in the run's driver.log (<binary> keep-log) before it does, and if the driver crashed, fails the attempts it left running with the crash's first frames (orca.crash). Beside its work each instance runs <binary> sample (orca.sample; in docker mode as a container with --pid host), whose lines its script uploads to the run's metrics/<role>-<instance>.jsonl with its log (<binary> put-log), every ORCA_CLUSTER_LOG_SECONDS and once when it ends, after telling the sampler to write a last line and stop (--until).

With --worker-procs N (auto: one per vCPU of the worker type) each worker instance's script starts N dista worker processes against the same head, rather than one, since a process runs one task at a time: each takes a port of its own, so they share the host. The script notes one that exits, forwards a SIGTERM to all of them, and shuts the instance down once the last has gone. Counts and costs stay in instances (workers, the budget); a worker's log is its processes' logs joined.

A pipeline registered with --workers MIN..MAX scales: its run starts MIN workers, which may be none, and every tick scale reads the head's demand (dista status --json, from port 6390 on the head's private address, through the pipeline's binary: <binary> dista-status, since dista's sockets can't share orca's binary with flare's) and asks dista.scale.decide what to do. It starts the workers asked for, up to MAX, and drains the ones named with dista drain; a drained worker exits once its work and results have moved, and its instance's script shuts it down. ORCA_SCALE_UP_AFTER (5 s), ORCA_SCALE_IDLE (60 s) and ORCA_SCALE_STEP (4) tune the policy. The budget counts MAX.

A run is scaled, and its workers are started, as its pipeline was registered when the run started (orca.runs.spec, kept at each start), so registering the pipeline again meanwhile doesn't strand a live run on settings it never had; its next start takes the new registration.

A run that needs workers (MAX above 0) whose head has tasks waiting and no worker for ORCA_NO_WORKERS_SECONDS (600; 0 turns it off) has the attempt that waits fail with no workers joined in 10 min (tasks waiting: N), and its instances terminated. Its driver stops beating with them, so the scheduler starts it again as it does a run whose process died, until its retries are spent. Since when it has waited is orca.runs.waiting_since, which the run's page shows. timeout_seconds on a step bounds an attempt whatever it waits for.

A pipeline's secrets (--secret NAME=ID) are read by each instance's script at start, after the preamble, with aws secretsmanager get-secret-value (ORCA_SECRET_FETCH replaces the command, which is given the ID), exported as NAME, given to the containers, and named in ORCA_SECRETS so that every log orca writes masks them.

Every tick, sweep terminates the instances of runs that aren't running at that start, and those older than ORCA_RUN_DEADLINE (a day). A head whose run has just ended is left ORCA_EC2_HEAD_GRACE seconds (60) to finish its script: keep the driver's log, upload its own and its samples, and shut itself down. Instances get ORCA_EC2_DATABASE_URL as ORCA_DATABASE_URL (the control instance's private address), the warehouse, the logs, the region and the scheduler's variables named in ORCA_RUN_ENV; ORCA_EC2_AMI, ORCA_EC2_SUBNET, ORCA_EC2_SECURITY_GROUP and ORCA_EC2_PROFILE say where and as what they run. cpu_millis is a run's instances' vCPUs, which the scheduler checks against ORCA_VCPU_BUDGET first.

The warm pool (ORCA_EC2_POOL_SECONDS, 150; 0 turns it off). An instance's script is then a loop over runs, and the next run of the same pipeline, started as the same instances, takes its head and workers rather than launching new ones:

  • The key. Each instance's row has a digest of what it was started as (pool_key): role, type, market, AMI, subnet, security group, profile, the pipeline's registry hash, and its script but for the first run's line, which holds the image, binary, secrets' IDs, the environment it was given and its worker processes. A run takes only instances of its own keys, so a new registration, image or setting never reuses old ones.
  • Back to the pool. A head's driver exits; a worker's <binary> pool-wait (orca.pool) sees its run end and the script kills its dista processes, which don't end with their head. Each uploads that run's logs and samples and runs <binary> pool-next, which marks the run's row idle and beats polled every second until it's handed a run. Its run's row stays live meanwhile, so idle instances count against the budget (a run of the same keys counts them as its own), and /ops/pools shows them as idle (pooled). A start of a pipeline waits while instances of its keys are still on their way there (at most the head's grace).
  • Hand-over (_take). In one transaction, the idle rows of the key whose run has ended, that weren't drained and that beat within ORCA_EC2_POOL_LIVE_SECONDS (5) are locked FOR UPDATE SKIP LOCKED, checked with DescribeInstances, ended (pooled: on to run N) and replaced by rows of the new run (pooled); pool-next sees its new row, records that it joined, and the next pass runs it: a new dista head process for the head, new worker processes pointed at the run's head, secrets read again, and logs and samples under the new run. A run takes pooled workers up to what it starts (MIN, and what scale adds), then launches the rest; the others stay pooled.
  • Failure. One handed a run that hasn't joined within ORCA_EC2_POOL_JOIN_SECONDS (30) is terminated (pooled: never took the run up) and replaced: a worker by a new worker, a head by new instances for the same start. The sweep ends idle instances after ORCA_EC2_POOL_SECONDS (pooled: idle N s, shut down), those that stop beating, and those that don't reach the pool within the head's grace; their rows end before they're terminated, so one handed on meanwhile isn't. An instance's tags keep its first run; the sweep goes by its live row. A run that's restarted, or a worker that was drained, ends as before.

Structs

Ec2Spec

struct Ec2Spec

One start of a run, as the instances it needs.

Implements Copyable, Movable

Fields
run: Int
start: Int
pipeline: String
image: String
binary: String
tasks: Bool: Whether the pipeline calls dista tasks: a head and workers.
head_type: String
worker_type: String
workers: Int
spot: Bool
allow_new_code: Bool
min_workers: Int: The workers it starts with; fewer than workers, it scales.
secrets: List[Tuple[String, String]]: Environment variable and Secrets Manager ID of each secret.
worker_procs: Int: The dista worker processes on each worker instance; 0 is one per vCPU of the worker type.
registry_hash: String: The pipeline's code, which an instance from the pool must have been started with.
env: List[Tuple[String, String]]: The pipeline's own variables (orca.toml's env), exported on every instance over those ORCA_RUN_ENV names, and so part of the pool's key.

Ec2Runner

struct Ec2Runner

Implements Movable

Fields
ec2: Ec2
mode: String
head_type: String
worker_type: String
deadline: Int
grace: Float64: How long a head may go on after its run ended, to shut itself down, before the sweep terminates it.
env: List[String]
up_after: Float64
idle: Float64
step: Int
short_since: Dict[String, Float64]: For each scaling run, run-start, since when it's been short of workers: decide's memory between ticks.
no_workers: Int: ORCA_NO_WORKERS_SECONDS (600): how long a run whose head has tasks waiting and no worker may go on before its attempt is failed; 0 turns it off.
pool_seconds: Float64: ORCA_EC2_POOL_SECONDS (150): how long an instance whose run ended waits, idle, to be handed another; 0 turns the pool off, and every instance ends with its run.
pool_live: Float64: ORCA_EC2_POOL_LIVE_SECONDS (5): how recently an idle instance must have asked for a run (pool-next) to be handed one.
pool_join: Float64: ORCA_EC2_POOL_JOIN_SECONDS (30): how long an instance handed a run has to take it up before it's terminated and replaced by a new one.

__init__

def __init__(out self)

head_of

def head_of(self, s: String) -> String

worker_of

def worker_of(self, s: String) -> String

procs_of

def procs_of(self, r: Ec2Spec) -> Int

The dista worker processes on each of the run's worker instances: worker_procs, or one per vCPU for auto (0).

cpu_of

def cpu_of(self, tasks: Bool, head_type: String, worker_type: String, workers: Int) -> Int

A run's instances' vCPUs, in thousandths.

pool_key

def pool_key(self, r: Ec2Spec, role: StringSpan[ImmStaticOrigin], itype: String, spot: Bool) -> String

What an instance of r's in role is started as, as a digest: its type, market, image, network and profile, the pipeline's code, and its script but for the first run's line (the image, binary, secrets' IDs, environment, worker processes and the rest). An idle instance is handed only to a run whose key is its own, so a new registration, image or setting never reuses one; empty with the pool off.

pool_keys

def pool_keys(self, r: Ec2Spec) -> List[String]

The keys of the head and, for a pipeline with tasks, the workers of r's runs; none with the pool off.

start

def start(self, mut db: State, r: Ec2Spec, fresh: Bool = False)

Starts the run's head instance, then its workers, and records them in orca.instances: from the pool first, where an idle instance of the same key is (unless fresh), else new ones. What r is, the pipeline's registration now, is kept with the run, so its workers are started as its head was however the pipeline is registered later.

settling

def settling(self, mut db: State, keys: List[String]) -> Bool

Whether instances of keys whose run just ended are still on their way to the pool (finishing the run's logs, stopping its processes): a start waits for them, a few seconds, rather than launching new ones beside them. At most the head's grace: after that the sweep terminates them.

scale

def scale(mut self, mut db: State) -> Tuple[Int, Int]

Grows and shrinks the workers of every running run that scales, from its head's demand; returns the workers started and drained.

policy

def policy(self, lo: Int, hi: Int, per: Int = Int(1)) -> Policy

The scaling policy over worker processes: lo to hi of them, and ORCA_SCALE_STEP instances of per at most at once.

sweep

def sweep(self, mut db: State) -> Int

Terminates the instances of runs that aren't running at their start, and those older than the deadline, but for the pool's: an instance whose run ended goes to the pool, and is terminated once it has been idle ORCA_EC2_POOL_SECONDS, or has stopped asking for a run. Returns how many it terminated.

Functions

vcpus

def vcpus(instance_type: String) -> Int

An instance type's vCPUs: from ORCA_EC2_VCPUS (type=n,...), else by its size, as Graviton families are sized.

Aliases

comptime HEAD_PORT = 6390
comptime HEAD_SECONDS = 10

How long <binary> dista-status or dista-drain may take, asking a run's head, before it's killed and the run skipped this tick.