Compute job lifecycle
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. Concepts and state vocabulary
- 2.2. The end-to-end flow
- 2.3. Stage 1 — register the engine package
- 2.4. Stage 2 — prepare an input bundle
- 2.5. Stage 3 — create the batch
- 2.6. Stage 4 — dispatch
- 2.7. Stage 5 — execute on a node
- 2.8. Stage 6 — validate, close, and collect
- 2.9. The whole loop in one script
- 3. Patterns used
- 4. See also
Return to Knowledge.
The queue of jobs is a table in the database, and a free node claims one job at a time. See How the compute grid queues and claims work for why work is not pushed from a stream, the options and the protocol.
1. Summary
A compute job is a risk run of the ORE engine on a worker node of the
ores.compute grid. The operator starts from the shell: an engine
package and an input bundle are uploaded first, then a batch of
identical jobs is created and dispatched. Dispatch stores one
workunit per job and the service queues a result for each in the work
ledger; a free wrapper node of the matching platform claims one,
downloads the engine package and the input, runs the
engine as a child process, and uploads the job's Output/ directory
back to storage. A submit message carries the outcome to the service,
which validates the result, records it on the workunit, and closes the
batch once every job has a result. The operator then downloads the
output archive of any result. This document walks that lifecycle end
to end against the implementation; see ores.compute for the component
breakdown and Compute Engine for the numerical engine itself.
2. Detail
2.1. Concepts and state vocabulary
The grid uses the BOINC vocabulary: a batch holds workunits, each
workunit is executed by one result per redundancy copy, and a
host is a worker node. apps, app versions, and platforms
describe what runs where.
| Entity | What it is | Key fields and states |
|---|---|---|
app |
A named engine family (e.g. ORE). |
name |
app_version |
One engine build of an app, tied to a wrapper compatibility level. | engine_version, wrapper_version, min_ram_mb |
app_version_platform |
The published engine bundle for one version on one platform. | platform_code, package_uri, sha256 |
batch |
A set of identical jobs submitted together, with an operator-chosen external_ref. |
status: open → dispatched → closed |
workunit |
One job of a batch. Carries the shared input bundle and the redundancy target. | canonical_result_id empty until a result is accepted; target_redundancy (1 for shell dispatch) |
result |
One execution attempt of a workunit on one host. | server_state: 1 Inactive, 2 Unsent, 4 InProgress, 5 Done; outcome: 1 Success, 3 ClientError, 4 NoReply |
host |
A wrapper node. Registers itself on its first heartbeat and gets a generated display name. | display_name, credit_total; judged online from the recency of its node samples |
The batch statuses and the result server_state transitions are
shown in compute_job_lifecycle_states.puml:
Figure 1: State vocabulary of the compute grid: the batch lifecycle, the workunit's canonical-result marker, and the result's server-side state machine (including the legacy pull channel that sets InProgress and the reaper that re-queues results of stale hosts).
Source: compute_job_lifecycle_states.puml. Regenerate with
plantuml -tpng doc/knowledge/architecture/compute_job_lifecycle_states.puml.
Two points are worth stating explicitly because they are easy to misread:
- The workunit has no status column. Its state is derivable: it has no
canonical_result_idyet, or it has one (the job is done). - The dispatcher creates the result row as
Unsent. The claim moves it toInProgressand binds the claiming host. The submit message moves it toDone. If the host stops extending its lease, the lease reaper returns the entry to the queue, and the next claim runs the job again. A job that uses its three attempts ends asDonewith outcomeNoReply.
2.2. The end-to-end flow
The interaction diagram below is the map for everything that follows:
Figure 2: The compute job lifecycle from the operator's shell session to the finished output archive: package and input upload, batch dispatch, queue and claim, node execution, result submission, and assimilation.
Source: compute_job_lifecycle_sequence.puml. Regenerate with
plantuml -tpng doc/knowledge/architecture/compute_job_lifecycle_sequence.puml.
The lifecycle has six stages:
- Register the engine — upload the ORE package and register the app.
- Prepare an input bundle — the job's working-directory snapshot.
- Create the batch — with the generated
batches addcommand. - Dispatch — with
compute dispatch-batch; the service stores workunits and queues one result per job. - Execute on a node — the wrapper downloads, verifies, runs, and uploads the output.
- Validate and close — the service accepts results, marks the workunit, and closes the batch; the operator downloads outputs.
2.3. Stage 1 — register the engine package
The engine package is the self-contained tarball a wrapper downloads
and runs. It holds the ore executable, every non-glibc shared
library it needs (under lib/, RPATH-relative), and the minimal
manifest.json the wrapper reads. ORE Studio vendors one package per
engine build under external/ore/packages/; the seed registers the ORE
1.8.17.0 build, ore-1.8.17.0-x64-linux.tar.gz, and the older
ore-1.8.16.0-4-g3b62ba248-x64-linux.tar.gz stays vendored beside it:
ore-1.8.17.0-x64-linux.tar.gz
├── manifest.json {"executable": "ore-1.8.17.0-x64-linux",
│ "args": ["Input/ore.xml"]}
├── ore-1.8.17.0-x64-linux
└── lib/ every shared library the engine needs
The manifest tells the wrapper which executable to spawn and with
which arguments. For ORE the argument is the job's input file
Input/ore.xml, resolved relative to the job's working directory —
which is exactly how a packaged Academy example is laid out. The
wrapper runs the engine with the job directory as its working
directory, so Input/, Output/, and log.txt inside ore.xml all
resolve inside that directory. See
Compute Engine for the engine itself and external/ore/packages/README.md
for how packages are built and self-tested.
Upload the vendored package with compute publish-package. The verb
uploads the tarball to storage under the app's package key, computes
and verifies its SHA256 server-side, and registers (or merges into)
the app, app_version, and app_version_platform rows:
connect $ORES_NATS_URL
login super_admin@localhost Secure-Password-123
compute publish-package ORE 1.8.17.0 x64-linux \
--wrapper-version 1.0.0 \
--file external/ore/packages/ore-1.8.17.0-x64-linux.tar.gz
logout
exit
publish-package merges rather than duplicates: the app row is keyed by
name, the app-version row by (name, engine_version,
wrapper_version), and the platform rows of a version are replaced
wholesale (previous platforms are preserved and the published one is
updated). Passing --wrapper-version 1.0.0 lines the upload up with
the row that
projects/ores.sql/populate/compute/compute_ore_app_seed.sql seeds —
run that seed, or run compute_populate.sql, to register the same app
plus the x64-linux platform row that compute_platforms_seed.sql
provides. The seed alone registers only the rows; the package bytes
still need the publish-package upload above.
Storage keys. All blobs live in the compute bucket of ores.storage
(/api/v1/storage/compute/ over HTTP). The seeded ORE package is
registered at:
| Kind | Key |
|---|---|
| Engine package | packages/ORE/1.8.17.0/ORE-1.8.17.0-x64-linux.tar.gz |
| Batch input bundle | input/{batch_id}.tar.gz (one per dispatched batch) |
| Job output archive | output/{result_id}.tar.gz (one per finished result) |
2.4. Stage 2 — prepare an input bundle
Every job of a batch runs against the same input bundle: a tarball
whose root holds the job's input directory, exactly as the engine
expects it. ORE Studio vendors the sample job
external/ore/packages/TA002_IR_Swap.tar.gz, built from the Academy
example TA002_IR_Swap (a 20-year EUR fixed-vs-float interest-rate
swap). Its layout is the working-directory snapshot the engine will
see after extraction:
TA002_IR_Swap.tar.gz
└── Input/
├── ore.xml the run's main config (inputs, Output/, log.txt)
├── market.txt, fixings.txt
├── todaysmarket.xml, pricingengine.xml, curveconfig.xml,
│ conventions.xml, irswap.xml
The sample's ExpectedOutput/ (the curves.csv, flows.csv,
npv.csv the engine should produce) lives in the Academy example and
is the reference for the package self-test, not part of the job
bundle. To build a bundle for another job, tar the input directory the
same way — the archive is uploaded verbatim, and the manifest argument
Input/ore.xml is what pins the engine to that layout.
2.5. Stage 3 — create the batch
The generated batches add creates a batch row with status open. The batch
is only a container plus an operator-chosen external_ref; nothing
runs yet:
connect $ORES_NATS_URL login super_admin@localhost Secure-Password-123 batches add ir-swap-2026-09-06 open system.new_record "first run" logout exit
An --smoke batch is bounded to 10–20 jobs and is what the smoke test
uses. See How do I add a compute batch from the shell?.
2.6. Stage 4 — dispatch
compute dispatch-batch takes the batch reference, the job count, the
app-version id, and the input tarball:
app_versions list # note the id of the ORE 1.8.17.0 row
compute dispatch-batch ir-swap-2026-09-06 15 \
<app_version_id> external/ore/packages/TA002_IR_Swap.tar.gz
Only an open batch can be dispatched, and the app version must exist
(the command resolves it before uploading anything). Dispatch then has
three parts:
- Upload the input bundle — the tarball goes verbatim to
input/{batch_id}.tar.gz; every workunit of the batch references that one shared bundle. - Store the workunits — one workunit row per job, each with
priority1,target_redundancy1, andinput_uripointing at the shared bundle. The batch status flips todispatchedonly after every workunit is saved; a save failure partway leaves the batchopenso dispatch can be retried. - Dispatch — each stored workunit raises a domain event that the
service's eventing listener feeds into the
workunit_dispatcher. For every workunit short of its redundancy target the dispatcher creates a result row (server_stateUnsent), queues it in the work ledger with the platforms that can run it, and rings thecompute.v1.ops.work_availablenotice so idle nodes ask at once. A dispatch that would pass the tenant's limit of waiting jobs is refused.
The ledger entry holds ids only. When a node claims the job, the
service builds the assignment from the workunit: package_uri and
package_sha256 come from the app version's published platform row
(hence the warning if a version has no package: the workunit is stored,
no entry is ever claimed, and the job sits Unsent forever).
output_uri is pre-assigned as output/{result_id}.tar.gz. The
storage token is minted at the claim, so it is fresh however long the
job waited.
The dispatcher converges on the redundancy target: a workunit whose result count is already at target gets no new result, and a partially failed dispatch is topped up by the next workunit event instead of stranding the workunit. See How do I dispatch a compute batch from the shell? for the recipe.
2.7. Stage 5 — execute on a node
A wrapper node is an ores.compute.wrapper process pointed at the
environment (--host-id is mandatory; the node also needs its tenant,
work_dir, heartbeat interval, and the storage HTTP base URL). On
startup it starts a claim loop. The loop sends compute.v1.ops.claim_work
with the host id and its own platform triplet, and the service answers
with one job or with no work:
request: host_id, platform
reply: result_id, workunit_id, package_uri, package_sha256,
input_uri, output_uri, storage_token, attempt
A node asks only when it is free, and asks again at once after a job.
When no work waits, it waits for the work_available notice, or for a
long timeout measured in minutes that covers a lost notice, and sends
nothing until one of them comes (Work is pushed to the grid, and an idle grid costs one claim per wrapper per timeout). The service chooses the job: the platform decides which
entries a node can run, the tenant with the fewest running jobs goes
first, and then priority and age. The claim holds the job under a
lease of five minutes. The heartbeat extends the lease, and a node that
dies mid-run loses it. The lease reaper then returns the job to the
queue, or ends it as no reply after three attempts.
Every heartbeat_interval (30 seconds by default) the node publishes
compute.v1.ops.heartbeat with its host id. The first heartbeat
auto-registers the host row and gives it a deterministic
adjective-animal display name carrying an eight-hex-digit discriminator from
the same id (e.g. clever-echidna-4f2a9c31), so two nodes are unlikely to
share a name by drawing the same words. A heartbeat from a known host writes
nothing: liveness is a series, and the grid judges a host online from the
recency of its newest node sample.
When telemetry is enabled the node additionally publishes node samples
on compute.v1.ops.node_sample.
On each claimed job the wrapper runs process_assignment:
- Ensure the package —
work_dir/packages/{app_version_id}/holds the extracted engine. If themanifest.jsonis missing, the node downloadspackage_uri, verifies its SHA256 againstpackage_sha256(a mismatch aborts the job — a corrupted or tampered package is never run), and extracts the archive. - Prepare the job directory —
work_dir/jobs/{result_id}/is created; the input archive is downloaded frominput_uriand extracted there. The engine will run with this directory as its working directory. - Run the engine — the executable named in the manifest is spawned
(Boost.Process; no shell involved) with the manifest arguments and
the job directory as
start_dir. For ORE that isInput/ore.xmlrelative to the job directory; ORE writes its run log toOutput/log.txtthere. The engine's own stdout/stderr go toengine.login the job directory, with the exact command line logged for reproduction. - Finish — on exit code 0 the wrapper archives the job's
Output/directory and uploads it tooutput_uri; on any other exit code there is no archive, and the error message carries the tail ofOutput/log.txt. The engine logs are published to the telemetry log-entry subject for the record. - Submit — the node sends a request/reply submit over
compute.v1.ops.submit_result, the trusted machine channel (wrapper nodes hold no user JWT, so the user-session-gated CRUD flow cannot accept their submissions):
subject: compute.v1.ops.submit_result
payload: result_id, host_id, output_uri,
outcome (1=Success, 3=ClientError), error_message
2.8. Stage 6 — validate, close, and collect
The submit handler (in ores.compute.core, queued as
ores.compute.service so one replica validates) is the validator and
assimilator of the grid:
- Record the result — the result row is marked
Donewith the outcome, the submitting host bound ashost_id, the output URI, and a received timestamp. A submit from a host that does not hold the claim, or for a result that is already done or retired, changes nothing. - Accept on the workunit — once the workunit has
target_redundancy(1 in shell dispatch) results in stateDone, the validator records the last-submitted result's id as the workunit'scanonical_result_id. Acceptance does not filter by outcome: with redundancy 1 a failed job (ClientError) still satisfies the target, so the grid does not re-dispatch it. Outcome checking is the operator's, throughcompute grid-statsor the result list — the smoke-test script asserts every result has outcome Success and fails otherwise. - Close the batch — when every workunit of the batch has a
canonical result, the batch status moves to
closed(even if some of those canonical results failed).
The operator watches the drain and collects outputs from the shell:
compute grid-stats --watch ir-swap-2026-09-06 # poll until the batch drains
workunits list # canonical_result_id per job
results list # result rows: state, outcome, host
compute download-output <result_id> <dest_dir> # fetch output/{result_id}.tar.gz
The downloaded archive is the Output/ directory the engine wrote —
for the TA002 sample, the curves.csv, flows.csv, and npv.csv
that the Academy example's ExpectedOutput/ predicts. See
How do I view compute grid stats from the shell? and
How do I download a compute result output from the shell?.
2.9. The whole loop in one script
The smoke-test library script drives the entire lifecycle in one
session: it adds a 15-job --smoke batch, dispatches it to the target
app version with the sample input, watches grid-stats until the
batch drains, and asserts that every online host was exercised and
every result has outcome Success. See
How do I smoke test the compute grid from the shell?.
3. Patterns used
- Competing Consumers — worker nodes claim jobs from one queue, and the database hands each job to one node.
- Bulkhead — a node claims only jobs for its own platform, so one platform's backlog does not hold another's.
- Queue-Based Load Leveling — the ledger holds each job until a free node claims it.
4. See also
- Data storage — the structure note that orders this cluster, and where to read this page in it.
- ores.compute — component group overview.
- Compute Engine — the ORE engine: what a run computes and how its output is produced.
- How do I add a compute batch from the shell? — batches add.
- How do I dispatch a compute batch from the shell? — compute dispatch-batch.
- How do I view compute grid stats from the shell? — grid-stats.
- How do I download a compute result output from the shell? — download-output.
- How do I download a compute job input from the shell? — download-input.
- How do I smoke test the compute grid from the shell? — the full loop in one script.
- Run a compute job end to end — the provisioning check and the output comparison.