Service architecture
In one minuteThe service is an asyncio API process that owns no numerical state, plus solver subprocesses of two kinds — one-shot job workers and long-lived sessions — over a file-based store that also holds the meshes clients upload. This page describes lifecycles, failure handling, caching and security.
- One API process, many solver processes. The service process validates requests, keeps queues and serves the documentation; it never computes a flow. Every solve runs in a subprocess — a one-shot worker per job, or a long-lived session — so a crashing or diverging solve cannot take the service down.
- State is files. Jobs, results, the result cache, uploaded meshes and pipeline records
live under the data directory (
gyre serve --data DIR). There is no database. Finished jobs and the cache survive a restart; running jobs becomeinterruptedand sessions are lost. - Limits are environment variables (
GYRE_MAX_SESSIONS,GYRE_MAX_QUEUED_JOBS,GYRE_MAX_DOF, the mesh-store limits); see the deployment guide. - The network is the trust boundary. The service binds all interfaces; without
GYRE_API_TOKENanyone who can reach the port can run jobs and upload meshes. - Health and identity:
GET /api/v1/health,GET /api/v1/info(revision, process id, start time),GET /api/v1/metrics(Prometheus).
Components#
| Component | Module | Responsibility |
|---|---|---|
| API application | gyre.service.app |
routing, validation, error shaping, static docs, MCP mount, metrics, auth |
| Job manager | gyre.service.jobs |
queue, worker subprocesses, cancellation, result cache, event streams |
| Worker | gyre.service.worker |
run one case; write status, events, artefacts |
| Session manager | gyre.service.sessions |
spawn/command/reap warm solver processes |
| Store | gyre.service.store |
atomic JSON files, event logs |
| Mesh store | gyre.service.meshes |
uploaded mesh files: names, validation, limits |
| Pipeline manager | gyre.service.pipelines |
definitions, runs as subprocesses in their own process group |
| MCP server | gyre.agent.server |
agent tools over the REST API |
Job lifecycle#
queued ──▶ running ──▶ succeeded
│ ├─────▶ failed (exception in the solver; traceback in error.txt)
└──────────▶└─────▶ cancelled (DELETE /jobs/{id})
interrupted (service restarted while the job was active)
- The request is resolved to a
CaseSpec(preset + dotted overrides, or a full document) and validated; errors return a structured body with ahint. A stringcaseis only looked up among the presets and is never opened as a file. A CaseSpec object withmesh.generator: filenames a mesh of the mesh store inmesh.params.path: the name is checked against the store’s pattern before anything is opened, the stored file is read for the size estimate, and its boundary groups are compared withdiscretization.boundaries. Anadaptrequest that cannot start is refused here as well. All of this file work runs in a worker thread, not on the event loop. - A cache key is computed from the canonical spec JSON, the gradient, error-estimate and
adaptrequests, the SHA-256 of a mesh file’s contents and the code fingerprint (gyre.io.code_fingerprint: version, revision and a hash of the package sources). If a successful job with that key exists, its artefacts are copied and the new job is returned alreadysucceededwithcached_fromset. A worker recomputes the key when it starts and records it, so a result is filed under the code that produced it. - Otherwise the job directory is created (
request.json,status.json) and the id queued. - A dispatcher task starts
python -m gyre.service.worker <dir>withGYRE_DEVICEchosen from the estimated problem size (GPU above 60 000 degrees of freedom, if one is present; GPU jobs are serialised). - The worker appends progress to
events.jsonl(one JSON object per line) and rewritesstatus.jsonatomically; on completion it writesresult.jsonand artefacts (solution.npz,flow.vtu,field.json). Withadaptthe worker runs the order selection (gyre.adapt.adaptive_order) through the samerun_case: oneadaptevent per degree, and the result of the final degree with anadaptationsection.
Mesh store#
The store is the directory <data_dir>/meshes, one file per name. PUT /meshes/{name}
checks the name against ^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}\.(msh|npz)$, refuses a body above
GYRE_MESH_MAX_BYTES from the Content-Length header and again while it streams the body
into a temporary file of the same directory, and then validates that file in a worker thread
with gyre.meshio.inspect_mesh, the reader every solver process uses. Validations run one at
a time. An .npz archive whose members would expand to more than four times the size limit
is refused from its directory, before any array is read. Only an accepted file is moved to
its name (os.replace), so an existing mesh is replaced by a valid one or not at all, and a
refused upload leaves nothing behind. The store is bounded in number and total size
(GYRE_MESH_STORE_MAX_FILES, GYRE_MESH_STORE_MAX_BYTES; 507 MeshStoreFull).
A ValueError of the reader carries a message written for the client and is returned as
422 InvalidMesh, with the temporary file name replaced by the client’s name. Any other
exception of the reader is also a 422 InvalidMesh, with the message
<name> could not be read as a mesh (<ExceptionType>); the detail goes to the log.
When the application starts it sets GYRE_MESH_DIR to the store (absolute path) and
GYRE_MESH_RESTRICT=1 in its own environment and restores the previous values at shutdown.
Job workers, session processes and pipeline runs inherit both, and
gyre.meshio.resolve_mesh_path then accepts only a bare file name inside that directory. The
confinement therefore holds in the processes that read the mesh, not only in the request
handler. A request for a case on a stored mesh that reaches an application without this
environment is answered 503 MeshStoreInactive; the name check (422 InvalidMeshName) and
the lookup in the store (404 UnknownMesh) come before that test.
GET /jobs/{id}/events?stream=true is a Server-Sent-Events stream that tails the event log
until the job reaches a final state; ?since=N returns a JSON page for polling clients (and
agents). Because the log is a file, a client can reconnect and resume from any sequence number.
Sessions#
A design loop evaluates one case many times at nearby inputs. A session process builds the
case once, keeps its compiled kernels, warm-starts each solve from the previous state in the
Newton regime and caches the linearisation between gradient calls. A session on a mesh file
reads the file once, at start-up, and reports the case digest of that moment; replacing or
deleting the mesh in the store afterwards does not affect it. Commands (evaluate,
gradients, adjoint, error_estimate, surface, distributions, field, state) travel
over a pipe; one command at a time per session. Idle sessions are reaped after 30 minutes
(GYRE_SESSION_IDLE_S), and the number of sessions is capped (GYRE_MAX_SESSIONS, default 4).
Failure handling#
| Failure | Behaviour |
|---|---|
| invalid case, override or request | 4xx with {"error": {"type", "message", "hint"}}, the shape of every error of the API; nothing is queued |
| unexpected exception in the API | 500 InternalError in the same shape, with an incident id as the only detail; the exception type and text are logged (logger gyre.service) under that id, because they can contain server paths |
| solver does not converge | job succeeded with converged: false, the reason and the outputs of the last iterate; requested gradients and error estimates are not computed and listed under skipped. A session reports it, keeps its last converged state as warm start, and refuses derivative, error-estimate, distribution and field calls (422 RuntimeError) until an evaluation converges |
| uploaded mesh unusable, too large, or the store is full | 422 InvalidMesh, 413 MeshTooLarge, 507 MeshStoreFull; nothing is stored |
| mesh deleted while a job on it is being submitted | 404 UnknownMesh |
| mesh deleted while a job on it is queued | the job fails when its worker builds the case |
| exception in a worker | job failed; traceback in the error.txt artefact |
| worker killed or crashed in native code | dispatcher marks the job failed (“exited without a result”) |
| cancel before the worker process exists (queued, waiting for the GPU, being spawned) | the job stays cancelled; no worker runs it |
| service restart | active jobs become interrupted; finished jobs and the cache persist |
| session process dies | the call that meets the death (or the first call after it) returns 410 SessionDead; the session is removed, so later calls return 404 NotFound |
| session process dies while starting | 500 SessionStartFailed |
| command error in a session | reported with its type; the session stays usable |
Security and exposure#
The service binds to all interfaces so that tailnet machines can reach it; the tailnet is the
trust boundary. Setting GYRE_API_TOKEN requires Authorization: Bearer … on /api/v1 (except
/health and /info) and on /mcp. Artefact downloads are restricted to a fixed allow-list of
file names; job ids are validated before touching the file system, and case strings are never
treated as paths. A CaseSpec object with mesh.generator: file names a mesh of the mesh
store; the service sets GYRE_MESH_RESTRICT=1 and GYRE_MESH_DIR for itself and its
subprocesses, so mesh.params.path is a bare file name inside the store and never a path.
Mesh uploads are as open as the rest of the API: without a token, anyone on the tailnet can
store files up to the size and number limits, and each file is parsed by the reader. The
reader checks declared counts against the contents of a file before it allocates, and
measures an .npz archive before it reads an array. Pipeline runs started through the
service load a case from a preset name or an inline specification only
(GYRE_PIPELINE_CASE_FILES=0).
Pipelines with shell stages run arbitrary commands as the service user and
are therefore excluded from the service and MCP by default; GYRE_PIPELINES_ALLOW_SHELL=1 in
the service’s environment opts in. The MCP endpoint disables
Host-header pinning because tailnet clients address the service by tailnet names and
addresses.
Observability#
GET /api/v1/metrics exposes Prometheus counters and gauges (jobs submitted / succeeded /
failed / cancelled, cache hits, queue length, running jobs, solve seconds, sessions,
evaluations, gradient calls, uptime, build info). GET /api/v1/info identifies the deployment:
version, commit, process id, start time, deployment name, whether documentation is built and from which
revision.
The agent interface is a client#
The MCP server holds no logic of its own: each tool is a described wrapper that calls the REST
API — in-process through an ASGI transport when mounted at /mcp, or over the network in the
standalone stdio proxy (python -m gyre.agent --service URL). Tool results summarise large
arrays (shape, extrema, norm) unless full_arrays is requested, and API errors are passed
through with their hints so an agent can correct itself. The mesh tools check the name against
the store’s pattern themselves, because a name with a / would address another URL, and
upload_mesh decodes its content (text or base64) into the request body of the PUT.