vmafx-controller gRPC service¶
vmafx-controller is the distributed platform controller for VMAFX Phase 4b. It is a single Go binary that exposes VMAF scoring and job orchestration over both gRPC (default :9090) and HTTP/JSON (default :8080). Configuration is by VMAFX_* environment variables only.
This page covers the gRPC interface. The HTTP routes are GET /healthz, GET /readyz, GET /metrics and POST /v1/score (cmd/vmafx-controller/main.go); their request and response shapes match the vmafx-server REST adapter. Authentication is in auth.md.
The controller exposes two gRPC services on the same port:
| Service | Purpose |
|---|---|
VmafxScoring | Direct scoring (retained from Phase 4a, ADR-0703) |
VmafxController | Job queue + node API (Phase 4b.1, ADR-0711) |
Quick start¶
-
Run the controller locally. It needs a
vmafbinary, for example fromcore/build-cpu.VMAFX_VMAF_BINARY=core/build-cpu/tools/vmaf \ VMAFX_MODEL_DIR=model/ \ VMAFX_HTTP_ADDR=:8080 \ VMAFX_GRPC_LISTEN=:9090 \ VMAFX_DB_PATH=/tmp/vmafx-controller.db \ VMAFX_SCORING_ROOTS=/media \ go run ./cmd/vmafx-controllerVMAFX_SCORING_ROOTSnames the inputs callers may score; without it every input is refused (scoring roots). -
Or build and run the container image.
Configuration¶
All settings are environment variables (12-factor); the controller has no CLI flags beyond --version since ADR-1119. Listen addresses are full addresses (:8080), not bare ports.
| Variable | Key | Type | Default | Chart value | Description |
|---|---|---|---|---|---|
VMAFX_HTTP_ADDR | http.addr | host:port | :8080 | controller.httpPort | HTTP listen address of /healthz, /readyz, /metrics and POST /v1/score, a full address. |
VMAFX_HTTP_TIMEOUTS_READ | http.timeouts.read | duration | 30s | Deadline for reading a whole request; 0 keeps the framework default. | |
VMAFX_HTTP_TIMEOUTS_HEADER | http.timeouts.header | duration | 5s | Deadline for reading the request headers (slow-client guard); 0 keeps the framework default. | |
VMAFX_HTTP_TIMEOUTS_WRITE | http.timeouts.write | duration | 60s | Deadline for writing a response; 0 keeps the framework default. | |
VMAFX_HTTP_TIMEOUTS_IDLE | http.timeouts.idle | duration | 120s | Keep-alive idle timeout; 0 keeps the framework default. | |
VMAFX_HTTP_TIMEOUTS_SHUTDOWN | http.timeouts.shutdown | duration | 30s | Drain time of the HTTP server at shutdown; 0 keeps the framework default. | |
VMAFX_HTTP_LIMITS_HEADER | http.limits.header | bytes | 1048576 | Largest request header block; 0 keeps the default. | |
VMAFX_HTTP_LIMITS_BODY | http.limits.body | bytes | 10485760 | Largest request body; 0 keeps the default. VMAFX_HTTP_LIMITS_UNLIMITED removes the cap. | |
VMAFX_HTTP_LIMITS_UNLIMITED | http.limits.unlimited | bool | false | Serve request bodies of any size; VMAFX_HTTP_LIMITS_BODY is then ignored. | |
VMAFX_GRPC_LISTEN | grpc.listen | host:port | :9090 | controller.grpcPort | gRPC listen address of VmafxScoring and VmafxController, a full address. |
VMAFX_GRPC_TLS | grpc.tls | bool | false | Serve gRPC over TLS; needs VMAFX_GRPC_CERT_FILE and VMAFX_GRPC_KEY_FILE. | |
VMAFX_GRPC_CERT_FILE | grpc.cert_file | path | (unset) | PEM certificate of the gRPC listener (with VMAFX_GRPC_TLS). | |
VMAFX_GRPC_KEY_FILE | grpc.key_file | path | (unset) | PEM private key of the gRPC listener (with VMAFX_GRPC_TLS). | |
VMAFX_GRPC_MAX_RECV_SIZE | grpc.max_recv_size | bytes | 4194304 | Largest gRPC message received; 0 keeps the gRPC default. | |
VMAFX_GRPC_MAX_SEND_SIZE | grpc.max_send_size | bytes | 4194304 | Largest gRPC message sent; 0 keeps the gRPC default. | |
VMAFX_VMAF_BINARY | vmaf.binary | path | vmaf on PATH | Path of the vmaf CLI behind the scorer; a missing binary stops the program at startup. | |
VMAFX_MODEL_DIR | model.dir | path | (unset) | Directory of the VMAF .json models the scorer loads. | |
VMAFX_STORE_BACKEND | store.backend | string | sqlite | set by the chart | Where jobs and node sessions live: sqlite or postgres (job persistence); anything else stops the controller. |
VMAFX_DB_PATH | db.path | path | vmafx/vmafx-controller.db under the user state directory | set by the chart | SQLite job database (sqlite). Unset, the controller uses $XDG_STATE_HOME (else ~/.local/state) on Linux and the BSDs and the user configuration directory on macOS and Windows; it never writes to the working directory. The image and the chart set /data/vmafx-controller.db. |
XDG_STATE_HOME | read directly | path | ~/.local/state | Base of the default VMAFX_DB_PATH on Linux and the BSDs; a relative value is ignored. | |
VMAFX_DB_DSN | db.dsn | string | (unset) | controller.store.postgresql.mode, controller.store.postgresql.external.secretName, controller.store.postgresql.external.secretKey | Secret. PostgreSQL connection string; required with postgres and by vmafx-controller migrate and import-sqlite. The standard PG* variables fill what it leaves out. |
VMAFX_STORE_LEASE_TTL | store.lease_ttl | duration | 60s | controller.store.leaseTTL | Lease of a pulled job (postgres); a negative value stops the controller. |
VMAFX_STORE_SESSION_TTL | store.session_ttl | duration | 60s | controller.store.sessionTTL | Lifetime of a node session without a heartbeat (postgres). |
VMAFX_STORE_SWEEP_INTERVAL | store.sweep_interval | duration | 5s | controller.store.sweepInterval | Period of the lease sweep (postgres). |
VMAFX_STORE_BACKOFF_BASE | store.backoff_base | duration | 5s | controller.store.backoffBase | Delay before a job whose lease expired is handed out again (postgres); doubles up to VMAFX_STORE_BACKOFF_MAX. |
VMAFX_STORE_BACKOFF_MAX | store.backoff_max | duration | 5m | controller.store.backoffMax | Cap of that delay (postgres). |
VMAFX_AUTH_DISABLED | auth.disabled | bool | false | auth.disabled | Turn token verification off (development only): every call acts as tenant dev with the admin role. Refused with a tenant source. |
VMAFX_JWKS_ENDPOINT | jwks.endpoint | URL | (unset) | auth.jwksEndpoint | JWKS endpoint of the identity provider. Required unless auth is disabled or a tenant source is set (then refused) (auth). |
VMAFX_AUTH_ISSUER | auth.issuer | string | (unset) | auth.issuer | Expected iss claim; required like VMAFX_JWKS_ENDPOINT. |
VMAFX_AUTH_AUDIENCE | auth.audience | string | (unset) | auth.audience | Expected aud claim; empty skips the check. Refused with a tenant source. |
VMAFX_AUTH_TENANT_CLAIM | auth.tenant_claim | string | tid | auth.tenantClaim | Claim that carries the tenant ID. Refused with a tenant source. |
VMAFX_AUTH_ROLES_CLAIM | auth.roles_claim | string | vmafx_roles | auth.rolesClaim | Claim that carries the list of roles. Refused with a tenant source. |
VMAFX_AUTH_TENANTS_SOURCE | auth.tenants.source | string | (unset) | set by the chart | kubernetes (the namespace's VmafxTenant resources) or file: verify each token against its tenant's provider (tenant registry). Excludes the provider variables above and VMAFX_AUTH_DISABLED. |
VMAFX_AUTH_TENANTS_FILE | auth.tenants.file | path | (unset) | YAML or JSON file of VmafxTenant documents (source file). | |
VMAFX_AUTH_TENANTS_NAMESPACE | auth.tenants.namespace | string | the pod's namespace | set by the chart | Namespace of the VmafxTenant resources (source kubernetes). |
VMAFX_AUTH_TENANTS_REFRESH | auth.tenants.refresh | duration | 30s | Re-read interval of the tenant source, 1s to 1h; the tenant set is refused after ten intervals without a successful read. | |
VMAFX_SCORING_ROOTS | scoring.roots | list | (unset: every input refused) | auth.scoringRoots | Comma-separated scoring roots of every caller without a tenant registry; {tenant} becomes the caller's tenant ID (scoring roots). Refused with a tenant registry. |
VMAFX_LOG_LEVEL | log.level | string | info | Log level: debug, info, warn or error, any case; an unknown value gives info. | |
VMAFX_LOG_FORMAT | log.format | string | auto | Log handler: auto (tint on a terminal, else JSON), tint or json; logs go to stderr. | |
VMAFX_OTEL_ENABLED | otel.enabled | bool | true | OpenTelemetry master switch; false installs no-op providers even with an endpoint. | |
VMAFX_OTEL_ENDPOINT | otel.endpoint | host:port | (unset) | OTLP/gRPC collector (otel-collector:4317); wins over OTEL_EXPORTER_OTLP_ENDPOINT. Neither set: no export (OpenTelemetry). | |
VMAFX_OTEL_INSECURE | otel.insecure | bool | true | Plaintext gRPC to the collector; false dials with TLS. | |
VMAFX_OTEL_SERVICE_NAME | otel.service.name | string | OTEL_SERVICE_NAME, else the binary name | service.name resource attribute. | |
VMAFX_OTEL_SERVICE_VERSION | otel.service.version | string | the build version | service.version resource attribute. | |
VMAFX_OTEL_SERVICE_NAMESPACE | otel.service.namespace | string | (unset) | service.namespace resource attribute. | |
VMAFX_OTEL_SAMPLE_RATIO | otel.sample.ratio | number | 1.0 | Parent-based trace sample ratio in [0, 1]; OTEL_TRACES_SAMPLER and its argument are not read. | |
VMAFX_OTEL_EXPORT_TRACES | otel.export.traces | bool | true | Export traces. | |
VMAFX_OTEL_EXPORT_METRICS | otel.export.metrics | bool | true | Export metrics. | |
VMAFX_OTEL_EXPORT_LOGS | otel.export.logs | bool | true | Export the logs signal; application logs are not bridged to it today. | |
OTEL_SERVICE_NAME | read directly | string | (unset) | service.name when VMAFX_OTEL_SERVICE_NAME is unset (OTel standard). | |
OTEL_SDK_DISABLED | read directly | string | (unset) | true (exactly) installs no-op providers (OTel standard). | |
OTEL_EXPORTER_OTLP_ENDPOINT | read directly | URL | (unset) | Collector as a URL (http://host:4317) when VMAFX_OTEL_ENDPOINT is unset; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for traces; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for metrics; set, export is on (OTel standard). | |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | read directly | URL | (unset) | Per-signal collector URL for logs; set, export is on (OTel standard). | |
POD_NAME | read directly | string | (unset) | Pod name (Kubernetes downward API), added to log lines and OTel resources as k8s.pod.name. | |
POD_NAMESPACE | read directly | string | (unset) | Pod namespace, added as k8s.namespace.name. | |
POD_IP | read directly | string | (unset) | Pod IP, added as k8s.pod.ip. | |
NODE_NAME | read directly | string | (unset) | Kubernetes node name, added as k8s.node.name. | |
SERVICE_ACCOUNT | read directly | string | (unset) | Service account name, added as k8s.serviceaccount.name. |
The table is generated from api/vmafx-platform.toml (API generation). How the authentication variables combine is on auth.md; the controller does not start when they are inconsistent or a configured tenant is invalid (tenant registry).
VmafxScoring service (direct scoring)¶
Retained from Phase 4a for backward compatibility. Clients that already talk to vmafx-server continue to work against vmafx-controller without changes.
service VmafxScoring {
rpc Score(ScoreRequest) returns (ScoreResponse);
rpc ScoreStream(stream ScoreStreamRequest)
returns (stream ScoreStreamResponse); // bidirectional, per-frame (ADR-0933)
rpc Health(HealthRequest) returns (HealthResponse);
}
Proto: proto/vmafx/v1/vmafx.proto, generated from api/vmafx-platform.toml. Generated stubs: gen/go/ (package vmafxv1).
Example: direct score¶
grpcurl -plaintext \
-d '{"reference":"/data/ref.yuv","distorted":"/data/dis.yuv","model":"vmaf_v0.6.1"}' \
localhost:9090 vmafx.v1.VmafxScoring/Score
VmafxController service (job queue + node API)¶
Phase 4b.1 distributed orchestration surface. Proto: proto/vmafx/controller/v1/controller.proto, generated from api/vmafx-platform.toml. Generated stubs: gen/go/controller/.
Client API¶
Used by the CLI, MCP server, and future Web UI to submit and track jobs. The full service definition is in api/vmafx-platform.toml.
| RPC | Caller | Purpose |
|---|---|---|
SubmitJob | client | Enqueue a job for the caller's tenant; returns its ID |
GetJob | client | Read the current state of one of the tenant's jobs |
CancelJob | client | Cancel one of the tenant's pending or running jobs; a running job's node stops it (see Cancelling a job) |
StreamJobs | client | Server-streaming snapshot of the tenant's jobs (optional status filter); a snapshot in Phase 4b.1 |
RegisterNode | node | Register a worker with its capability; the session belongs to the caller's tenant |
Heartbeat | node | Keep the registration alive; the answer names the node's running jobs that were cancelled |
PullWork | node | Receive the next matching job of the node's tenant |
ReportResult | node | Report progress or the final result of a job assigned to the node |
Every call carries the tenant of its token, and every job it reads or writes belongs to that tenant; the role each call needs and the tenant rules are in Auth gateway.
Submit a job¶
The reference and the distorted input must lie under the tenant's scoring roots, or the call fails with PERMISSION_DENIED; the node checks them again, following symlinks, before it reads them.
grpcurl -plaintext \
-d '{"scoring":{"reference":"/data/ref.yuv","distorted":"/data/dis.yuv","backend":"cuda"}}' \
localhost:9090 vmafx.controller.v1.VmafxController/SubmitJob
# → {"jobId": "550e8400-e29b-41d4-a716-446655440000"}
Poll job status¶
grpcurl -plaintext \
-d '{"jobId":"550e8400-e29b-41d4-a716-446655440000"}' \
localhost:9090 vmafx.controller.v1.VmafxController/GetJob
# → {"id":"...","status":"COMPLETED","scoring":{...},"assignedNode":"node-abc"}
Cancel a job¶
grpcurl -plaintext \
-d '{"jobId":"550e8400-e29b-41d4-a716-446655440000"}' \
localhost:9090 vmafx.controller.v1.VmafxController/CancelJob
# → {"ok":true,"message":"cancellation requested"}
The job becomes CANCELLED at once and stays so. A pending job is never handed out. A running job keeps running on its node until the node's next heartbeat, at most VMAFX_CONTROLLER_HEARTBEAT_INTERVAL (10 s) later: the heartbeat lists the jobs the node runs, the controller answers with those that were cancelled, and the node cancels them, which kills their vmaf processes, and reports them as failed (cancelled by the controller: ...). That report does not change the job's status (ADR-1567).
Node API¶
Used by vmafx-node worker processes to pull and report work (the node RPCs in the table above).
Node lifecycle (vmafx-node implements it when VMAFX_CONTROLLER_ADDR is set; see node.md):
- On startup, the node calls
RegisterNodewith its capability (GPU vendor, available backends, concurrency slots). The controller returns anode_idand asession_token. - The node calls
Heartbeatevery ~10 s with thenode_id, thesession_tokenand the IDs of the jobs it runs (running_job_ids, at most 64). The answer'scancel_job_idsnames those of them that were cancelled; the node stops them. A node that misses heartbeats for 60 s is evicted, and its running jobs return toPENDINGahead of newer work, so another node picks them up. A node that comes back registers again; if it still finishes such a job, the first final result the controller receives is kept. - When the node has capacity, it calls
PullWork. The controller assigns the oldestPENDINGjob whosebackendrequirement matches the node's capabilities. - After the job completes (or fails), the node calls
ReportResultwithfinal=true.
Job lifecycle¶
Text description
- Job status in the queue: pending, running, completed, failed, cancelled.
- Boxes: Client (SubmitJob, CancelJob), vmafx-controller (queue, node registry, scheduler), vmafx-node (RegisterNode, Heartbeat).
- Client → vmafx-controller (SubmitJob).
- vmafx-node → vmafx-controller (PullWork).
- vmafx-node → vmafx-controller (ReportResult).
- pending → running (PullWork) → completed (ok).
- running → failed (error).
- pending → cancelled (CancelJob).
- running → cancelled (CancelJob).
- running → pending (node evicted).
- Scenario 1, Job runs: A submitted job is pulled by a matching node and reported. The client submits a job; it is pending. A node with the required backend pulls work. The scheduler assigns the oldest matching job. The node reports the result. The job is completed, or failed on an error.
- Scenario 2, Node lost: An evicted node gives its jobs back. A node runs a job. No heartbeat for 60 s: the node is evicted and the job is pending again.
- A node that misses heartbeats for 60 s is evicted, and its running jobs return to pending.
- A running job that is cancelled is stopped by its node after the next heartbeat names it.
- PullWork assigns the oldest pending job whose backend the node lists in its capability.
Backend capability matching¶
A job's scoring.backend field specifies which backend the job requires (e.g. "cuda", "sycl", "cpu"). If empty, any node can accept the job. A node must list the required backend in its capability.backends to receive the job.
Job persistence¶
The controller keeps jobs and node sessions in one of two backends, chosen with VMAFX_STORE_BACKEND:
| Backend | Where state lives | Replicas | Use |
|---|---|---|---|
sqlite (default) | The embedded SQLite queue at VMAFX_DB_PATH; node sessions in memory | one | single host, development; transitional until the SQLite store of the standalone profile replaces it (ADR-2350) |
postgres | PostgreSQL at VMAFX_DB_DSN (jobs, attempts, node sessions); River jobs in the same database | any number | clusters |
PostgreSQL backend¶
export VMAFX_DB_DSN='postgres://vmafx:secret@db.example:5432/vmafx?sslmode=verify-full'
vmafx-controller migrate # schema of the store and of River
VMAFX_STORE_BACKEND=postgres vmafx-controller # every replica the same way
- Schema.
vmafx-controller migrateapplies the store's migrations and River's, then exits; run it once per release, before the controllers of that release start. A controller whose database is below the schema it needs reports not ready on/readyzand logs both versions. A controller that starts before its database answers, or before River's tables exist, does not exit: it retries starting River every 2 s (for up to 30 minutes) and reports not ready until River runs, so its replicas can start in any order with the database and the migration. - Node work is leased.
PullWorkgives a node a job with a lease (VMAFX_STORE_LEASE_TTL, default 60 s); everyHeartbeatnaming the job renews it. A lease that is not renewed expires: a sweep that runs everyVMAFX_STORE_SWEEP_INTERVAL(default 5 s) on one replica returns the job to pending after a backoff (VMAFX_STORE_BACKOFF_BASE5 s, doubling up toVMAFX_STORE_BACKOFF_MAX5 min), and fails it after three lost leases. - One result per job. A result is recorded only for the attempt the reporting session holds; a node that lost its lease and reports late is refused (
PERMISSION_DENIED), and the job keeps the result of the attempt that replaced it. A retried report of an accepted result succeeds without a second write. - Sessions survive controller restarts. The node ID returned by
RegisterNodenames a session stored in the database (VMAFX_STORE_SESSION_TTL, default 60 s), so a node keeps working through a controller restart or a replica failure without registering again. A node process that restarts registers again; the jobs it was running return to the queue when their leases expire. - Tenants. Every query names the caller's tenant, and row-level security hides other tenants' rows from the database role the controller uses. That role must not be a superuser (a superuser bypasses row-level security).
Moving from the SQLite queue¶
import-sqlite copies every job of the SQLite queue file (read-only) into the PostgreSQL store: pending and running jobs arrive pending, finished jobs keep their result, and a second run skips what the first copied. Jobs that carry no tenant (a queue run with authentication disabled) are refused unless --empty-tenant <tenant> names the tenant to give them.
SQLite backend¶
On a restart of a controller with the SQLite backend:
PENDINGjobs are reloaded and re-queued in submission order.RUNNINGjobs are reset toPENDING(their assigned nodes are gone).COMPLETED,FAILED, andCANCELLEDjobs are retained for audit.
Schema: cmd/vmafx-controller/queue/schema.sql.
Prometheus metrics¶
/metrics exposes the following in Prometheus exposition format. The scoring counters and histogram are the metric set shared with vmafx-server (pkg/observability/observability.go), so they carry the vmafx_server_ prefix; the queue and node metrics carry vmafx_controller_.
| Metric | Type | Description |
|---|---|---|
vmafx_server_score_requests_total | Counter | Direct Score requests (HTTP + gRPC VmafxScoring) |
vmafx_server_score_errors_total | Counter | Direct Score requests that returned an error |
vmafx_server_score_duration_seconds | Histogram | Direct scoring latency |
vmafx_server_health_requests_total | Counter | Health / /healthz calls |
vmafx_server_ready_requests_total | Counter | /readyz calls |
vmafx_controller_jobs_pending | Gauge | Current number of PENDING jobs |
vmafx_controller_jobs_running | Gauge | Current number of RUNNING jobs |
vmafx_controller_nodes_live | Gauge | Current number of live registered nodes |
vmafx_controller_jobs_submitted_total | Counter | Jobs submitted via SubmitJob RPC |
vmafx_controller_jobs_completed_total | Counter | Jobs completed successfully |
vmafx_controller_jobs_failed_total | Counter | Jobs that ended in failure |
There is no cancelled-jobs counter.
Graceful shutdown¶
The controller listens for SIGTERM and SIGINT. On receipt it:
- Stops accepting new connections.
- Waits up to 30 seconds for in-flight requests to drain.
- Exits with code 0.
Jobs remain in the SQLite database; the next controller instance reloads them.
Further reading¶
- ADR-0711 — Phase 4b.1 decision record.
- ADR-0709 — Phase 4b umbrella architecture.
- ADR-0703 — Phase 4a origin (vmafx-server).
- REST adapter —
/healthz,/readyz,/metrics,/v1/scorerequest and response shapes. - Auth gateway — JWT authentication and tenant isolation.
- k8s deployment guide — Helm chart configuration.