Architecture
Overview
Metis is built around three decoupled layers connected by a message bus. The HTTP API never executes workflows; execution happens entirely in isolated engine processes that communicate asynchronously via NATS.
Components
metis-api
The HTTP layer. Responsibilities:
- Validate incoming
POST /runsagainst engine config (parameter types, allowed values, denied flags) - Persist run record to PostgreSQL
- Publish run submission to NATS topic
run.submit.<engine_name> - Serve paginated logs and current state from PostgreSQL
- Serve SSE streams for real-time status and log updates
- Handle cancellation by reading the PID from Valkey and sending SIGTERM
The API is stateless beyond database connections. Multiple replicas can run behind a load balancer.
NATS
The async message bus. Decouples run submission from execution so that:
- The API returns immediately after persisting the run
- Engine processes can be restarted without losing queued work
- Multiple engine types subscribe to separate topics (
run.submit.Nextflow,run.submit.Snakemake, etc.)
NATS is also used for the real-time status SSE stream: the engine publishes state transitions and the API relays them to connected clients.
Engine Runtime
The execution layer. A single binary that:
- Subscribes to the
run.submit.<engine_name>topic - Reads
engine-config.yamlon startup - On receiving a run message:
- Builds the CLI command from
commandTemplate, substituting validated parameters - Creates the working directory tree
- Spawns the workflow subprocess
- Captures stdout/stderr line-by-line and writes to
log_lines - Tracks state transitions and publishes them to NATS
- Registers the process PID in Valkey for cancellation
Engines implement the Engine trait to provide engine-specific result parsing, task log extraction, and lifecycle hooks:
pub trait Engine: Send + Sync {
fn new() -> Self;
async fn get_workflow_results() -> Result<...>;
async fn get_task_logs() -> Result<...>;
// Lifecycle hooks (default no-ops)
async fn pre_run(&self, ctx: &HookContext) -> ...;
async fn on_success(&self, ctx: &HookContext) -> ...;
async fn on_failure(&self, ctx: &HookContext) -> ...;
async fn post_run(&self, ctx: &HookContext) -> ...;
}HookContext provides run metadata, an HTTP client, and a ServiceTokenClient for authenticated S2S calls. Hook failures are logged but do not fail the run.
Everything else — NATS subscription, process execution, log capture, state transitions — is handled by the shared runtime.
PostgreSQL
Persistent state. Four tables:
| Table | Contents |
|---|---|
runs | Run metadata: state, URL, params, etc |
run_logs | Aggregate log: exit code, command |
log_lines | Streamed output lines by (run, seq) |
task_logs | Per-task execution records |
Runs are soft-deleted (deleted_at) rather than hard-deleted.
Valkey (Redis-compatible)
Ephemeral runtime data:
- Engine heartbeats — each engine instance writes a heartbeat; the API reads this to report available engines in
/service-info - PID map —
run_id → PIDso the API can send SIGTERM on cancel without knowing which engine node holds the process
Run Lifecycle
End-to-end flow from client submission to completion:
Run States
Terminal states: COMPLETE, EXECUTOR_ERROR, CANCELED, SYSTEM_ERROR
Crate Structure
See Engine Internals for the crate breakdown and how to add a custom engine.
