---
title: Service architecture
summary: The 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.
section: engineering
order: 3
level: 2
keywords: [FastAPI, jobs, sessions, mesh store, SSE, cache, failure modes, security, MCP]
---
<!-- gyre docs: expanded Markdown of engineering/service-architecture.md (generated tables, verification numbers and chart data are written out). Base level of this page: L2 Practice; passages at another level are bracketed by 'begin level N' / 'end level N' comments and start with [LN Name]. Built from revision ceb1f4b0be. -->

<!-- begin level 1 (Essentials) -->
**[L1 Essentials] In brief: what an operator needs to know**

* **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 become `interrupted` and 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](../guide/deployment.md).
* **The network is the trust boundary.** The service binds all interfaces; without
  `GYRE_API_TOKEN` anyone 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).

<!-- end level 1 -->

## 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)
```

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] What happens on submit**

1. The request is resolved to a `CaseSpec` (preset + dotted overrides, or a full document) and
   validated; errors return a structured body with a `hint`. A string `case` is only looked up
   among the presets and is never opened as a file. A CaseSpec object with `mesh.generator: file`
   names a mesh of the mesh store in `mesh.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 with `discretization.boundaries`. An `adapt` request
   that cannot start is refused here as well. All of this file work runs in a worker thread,
   not on the event loop.
2. A cache key is computed from the canonical spec JSON, the gradient, error-estimate and
   `adapt` requests, 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 already `succeeded` with `cached_from` set. A worker recomputes
   the key when it starts and records it, so a result is filed under the code that produced it.
3. Otherwise the job directory is created (`request.json`, `status.json`) and the id queued.
4. A dispatcher task starts `python -m gyre.service.worker <dir>` with `GYRE_DEVICE` chosen from
   the estimated problem size (GPU above 60 000 degrees of freedom, if one is present; GPU jobs
   are serialised).
5. The worker appends progress to `events.jsonl` (one JSON object per line) and rewrites
   `status.json` atomically; on completion it writes `result.json` and artefacts
   (`solution.npz`, `flow.vtu`, `field.json`). With `adapt` the worker runs the order
   selection (`gyre.adapt.adaptive_order`) through the same `run_case`: one `adapt` event per
   degree, and the result of the final degree with an `adaptation` section.

<!-- end level 3 -->

## Mesh store

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] Files from clients**

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.

<!-- end level 3 -->

<!-- begin level 2 (Practice) -->
**[L2 Practice] Progress streaming**

`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.

<!-- end level 2 -->

## Sessions

<!-- begin level 2 (Practice) -->
**[L2 Practice] Why a second execution model**

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).

<!-- end level 2 -->

## 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

<!-- begin level 2 (Practice) -->
**[L2 Practice] Assumptions**

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.

<!-- end level 2 -->

## 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

<!-- begin level 3 (Mechanics) -->
**[L3 Mechanics] MCP over the same API**

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`.

<!-- end level 3 -->
