Reference Topologies
The same engine binary — and the same Rust crate / Python wheel for the
embedded case — serves every deployment shape below. Nothing here is a
different code path: each shape is a point on the backend-driver
configuration surface
(catalog, trigger broker, object storage) plus a process count. This page
pins each shape to the concrete artifact that realises it: a config, a
tested deploy/ Compose file, or a kustomize manifest tree.
Shape A — single-process embedded
Artifact: the jammi-db / jammi-ai Rust crates, or the jammi-ai
Python wheel. No server process.
The workspace defaults ARE Shape A: SQLite catalog under artifact_dir,
result tables on local disk, the in-memory trigger broker, an in-process
model cache. No config file is required.
#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use jammi_db::config::JammiConfig;
use jammi_ai::session::InferenceSession;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = JammiConfig::load(None)?; // SQLite + local fs + in-memory broker
let session = InferenceSession::new(config).await?;
let _ = session;
Ok(())
}
}
import jammi
# `file://` resolves to the compiled in-process engine (the `jammi-ai[embedded]`
# extra), constructed through the same `JammiConfig` resolution chain as
# `jammi-server` — via the engine's own `jammi_native.open_local`.
db = jammi.connect("file:///var/lib/jammi")
Notebooks, the jammi CLI against a local directory, single-machine batch
jobs, and laptop development all run this shape unmodified — see
Quickstart: Rust and Quickstart:
Python.
Shape B — single-tenant server
Artifact: deploy/docker-compose.yml
— one jammi-server process, a Postgres catalog, and a JetStream broker,
tested end to end by the compose-smoke workflow on every push to main
and nightly.
# Shape B: `jammi-server` against Postgres (catalog) + JetStream (broker) —
# a worked example of a *configuration* change, not orchestration policy
# (orchestration is the deployer's runtime; see `docs/guide/src/philosophy.md`).
# Every knob below is one this compose file only sets, never invents: the
# image's env-only configuration layer (`crates/jammi-db/src/config/`)
# resolves `JAMMI_CATALOG__POSTGRES__URL`, `JAMMI_BROKER__JET_STREAM__URL`,
# and `JAMMI_SERVER__SERVICES` exactly as documented in
# `docs/guide/src/reference-topologies.md`.
#
# `jammi-server`'s healthcheck is exec-form `jammi-server probe` — the
# runtime image is distroless (no shell, no curl/wget), so the readiness
# probe is a generic CLI subcommand the binary itself ships (see
# `crates/jammi-server/src/probe.rs`), the same shape as Postgres's own
# `pg_isready`.
#
# `secrets` live in a git-ignored `.env` file next to this compose file;
# `deploy/.env.example` documents the one required key.
#
# This file has no compute-tier service: Kubernetes runs the compute tier as
# a `StatefulSet` (`deploy/kubernetes/overlays/shape-d`) because a rank's
# peer address must survive a pod restart; Compose has no ordinal-stable
# identity primitive, so a multi-rank Compose deployment lists each rank as
# its own named service instead (`deploy/kubernetes/README.md`).
services:
jammi-server:
image: ${JAMMI_IMAGE:-ghcr.io/f-inverse/jammi-ai-server:latest}
# For local developers only: `docker compose build` compiles this repo's
# own Dockerfile (CPU variant) instead of pulling the published image.
# The CI smoke override (`docker-compose.ci.yml`) drops this block and
# points `JAMMI_IMAGE` at the image it already built + loaded.
build:
context: ..
dockerfile: Dockerfile
environment:
JAMMI_CATALOG__POSTGRES__URL: postgres://jammi:jammi@postgres:5432/jammi
JAMMI_BROKER__JET_STREAM__URL: nats://nats:4222
JAMMI_SERVER__SERVICES: all
JAMMI_AUDIT_MASTER_KEY: ${JAMMI_AUDIT_MASTER_KEY:?set it in .env}
ports:
# Loopback-bound: the deployer publishes these for a
# TLS-terminating proxy running on the same host to reach (see
# `docs/guide/src/security.md`'s transport-encryption section), never
# for direct exposure to an untrusted network. TLS termination is the
# deployer's runtime, not this compose file's job.
- "127.0.0.1:8081:8081" # gRPC + Flight SQL
- "127.0.0.1:8080:8080" # HTTP side-channel (/healthz, /readyz, /metrics)
volumes:
# Artifacts (indices, materialized Parquet, `index_cache`) and the
# Hugging Face Hub cache (`HF_HOME`, set by the image) — a named
# volume so docker provisions ownership for distroless's nonroot
# uid (65532); a bind mount would need host-side `chown` first.
- jammi-data:/var/lib/jammi
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_healthy
restart: unless-stopped
# `docker stop` / `docker compose down` send SIGTERM (DRAIN: the in-flight
# job finishes) and SIGKILL after this grace; 120 s covers a small epoch.
# SIGINT is RELEASE — the job's lease is handed back at once and the next
# process to claim it resumes it. `docker compose kill -s SIGINT` delivers
# that signal but the daemon records a kill as a MANUAL stop, and a manually
# stopped container is exempt from `restart: unless-stopped`: nothing comes
# back on its own. To release AND have this policy restart the container,
# signal the process from the host (`kill -INT $(docker inspect -f
# '{{.State.Pid}}' <container>)`, what tests/compose/shape_b_release.py
# does), or follow a compose kill with `docker compose start jammi-server`.
stop_grace_period: 120s
healthcheck:
test: ["CMD", "/usr/local/bin/jammi-server", "probe"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
postgres:
image: postgres:16
environment:
POSTGRES_USER: jammi
POSTGRES_PASSWORD: jammi
POSTGRES_DB: jammi
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "jammi"]
interval: 5s
timeout: 5s
retries: 12
nats:
image: nats:2.10-alpine
command: ["-js", "-m", "8222"]
healthcheck:
# The image ships no shell — `wget` is the one HTTP-capable binary
# nats:2.10-alpine's base carries; `nats-server --help` (no shell
# dependency either) would prove only that the binary exists, not
# that JetStream came up.
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
interval: 5s
timeout: 5s
retries: 12
volumes:
jammi-data:
postgres-data:
The jammi-server service’s configuration is entirely environment-driven,
through the same JAMMI_<PATH> layer every deployment shape uses:
JAMMI_CATALOG__POSTGRES__URL— the Postgres catalog connection.JAMMI_BROKER__JET_STREAM__URL— the JetStream broker connection.JAMMI_SERVER__SERVICES—allhere (every compiled-in service tier); see Service tiers for narrower selections.JAMMI_AUDIT_MASTER_KEY— read fromdeploy/.env(copydeploy/.env.example); an absent key makes Compose itself refuse to come up (${JAMMI_AUDIT_MASTER_KEY:?…}) before the container ever starts. Signing is not a “disabled” mode: it stays configured and, unset, only fails at the first audit write; a present-but-malformed key instead makesjammi-serverrefuse to start.
The published ports (8081, 8080) are bound to 127.0.0.1, not
0.0.0.0: the compose file publishes them for a TLS-terminating proxy
running on the same host to reach, never for direct exposure to an
untrusted network (see Security Posture).
The healthcheck is exec-form jammi-server probe (see jammi-server probe below) — the runtime image is
distroless and ships no shell, so a curl/wget-based HEALTHCHECK is not
an option; probe is the same generic-CLI shape as Postgres’s own
pg_isready.
What the smoke proves. tests/compose/remote_smoke.py is the shared
smoke oracle; shape_b_remote.py (this Compose shape) and
shape_c_kube_remote.py (the Kubernetes shape below) are its two drivers,
differing only in how they restart the server and in what they assert
afterwards — durability on the Compose volume here, the shared catalog on
the emptyDir pods there. The oracle asserts
get_server_info().broker == "jet_stream" — the RUNTIME driver kind, which
fails if JAMMI_BROKER__JET_STREAM__URL were ever dropped from the compose
file, unlike the compile-time features list alone — registers the bundled
patents.parquet fixture, generates embeddings with the bundled tiny_bert
fixture, searches for the stored vector’s own nearest neighbor (an exact
self-hit), then restarts the jammi-server container and repeats the same
search. It asserts durability across the restart: identical result ids and
scores within 1e-6, and an unchanged list_index_segments — the segment
bundle on the Postgres-backed catalog and the jammi-data volume survives a
container restart, not merely that the server answers again afterward. The
workflow itself queries the postgres container directly afterward for the
sources table’s row count — the matching runtime oracle on the catalog
side, failing if JAMMI_CATALOG__POSTGRES__URL were ever dropped.
Shape C — multi-tenant server
Artifact: N jammi-server replicas behind a load balancer, a shared
Postgres catalog, a shared object store, and a shared JetStream (or
Postgres-as-)broker. Every replica runs the identical config; only the
process count differs from Shape B.
artifact_dir = "/var/lib/jammi"
[catalog.postgres]
url = "${POSTGRES_URL}?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16
max_lifetime_secs = 1800
[broker.jet_stream]
url = "nats://${NATS_HOST}:4222"
retention_seconds = 604800
credentials = { file = "/var/run/secrets/nats.creds" }
[lease]
duration_secs = 30
heartbeat_secs = 10
[server]
services = ["core", "event", "eval"]
[worker]
enabled = false
The env-only equivalent (see Deploy as a Server: a production shape, entirely from the environment for the fuller walkthrough, including the object-store result root and a file-backed audit signing key):
export JAMMI_CATALOG__POSTGRES__URL="postgres://jammi:${POSTGRES_PASSWORD}@postgres.internal:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
export JAMMI_CATALOG__POSTGRES__POOL_SIZE=16
export JAMMI_BROKER__JET_STREAM__URL="nats://nats.internal:4222"
export JAMMI_BROKER__JET_STREAM__CREDENTIALS__FILE=/run/secrets/nats.creds
export JAMMI_LEASE__DURATION_SECS=30
export JAMMI_LEASE__HEARTBEAT_SECS=10
export JAMMI_SERVER__SERVICES=all
jammi-server
The guarantee. With every replica pointed at the same Postgres catalog,
the one lease primitive documented in Catalog Backend and Trigger
Broker: Multi-writer
safety holds across the whole
fleet: concurrent writers never corrupt or reap each other’s building
tables; a crashed replica’s rows are reclaimed at the next session boot or
by jammi reconcile; migrations are serialised by an advisory lock.
[lease] (duration_secs / heartbeat_secs, JAMMI_LEASE__*) is the one
timing knob every leased catalog row shares across the fleet — see
Configuration for its full field reference.
Kubernetes (deploy/kubernetes)
Orchestration — which scheduler, how replicas are placed, ingress, TLS
termination, autoscaling — is the deployer’s runtime, not the engine’s (see
“How it deploys”).
deploy/kubernetes/base is the whole shape the engine cares about — a
Deployment with readinessProbe against /readyz and runAsNonRoot, a
Service, a ConfigMap of the non-secret knobs, an emptyDir for scratch;
ingress, TLS, autoscaling, network policy and each cloud’s managed-service
annotations are the deployer’s overlay, and the seam is a kustomize patch on
the Deployment/Service metadata, never a change to the engine’s knobs. Every
PR validates kustomize build + kubeconform --strict --kubernetes-version 1.34.11 over every kustomization in the tree, in ci.yml’s Guard (kubernetes manifests); the kube-smoke workflow additionally stands the
ci overlay up on a real kind cluster on push to main, nightly, on
manual dispatch, and on any pull request that touches
deploy/kubernetes/** or tests/compose/**.
# Shape C's query tier -- the artifact the guide's Kubernetes section
# (docs/guide/src/reference-topologies.md) includes directly. See
# `deploy/kubernetes/README.md` for the boundary this file does and does not
# cover.
apiVersion: apps/v1
kind: Deployment
metadata:
name: jammi-server
spec:
# Every replica runs the identical config against the same Postgres catalog
# and JetStream broker; the one lease + advisory-lock mechanism
# (docs/guide/src/reference-topologies.md#shape-c--multi-tenant-server,
# docs/guide/src/catalog-and-broker.md#multi-writer-safety; #479 closed)
# is what makes concurrent replicas safe. Raise or lower freely.
replicas: 3
selector:
matchLabels: { app: jammi-server }
template:
metadata:
labels: { app: jammi-server }
spec:
# Termination grace is the Kubernetes default (30 s): SIGTERM drains —
# in-flight requests finish, an idle WaitJob/Subscribe stream ends with
# UNAVAILABLE "server draining" — then SIGKILL. A training job still in
# flight past the grace resumes from its last epoch bundle after one
# lease window and consumes one attempt; raise the grace to the compute
# overlay's value (overlays/shape-d) when this base runs training in
# earnest.
securityContext:
runAsNonRoot: true
runAsUser: 65532
# The scratch emptyDir below must be writable by uid 65532 without
# relying on kubelet's default 0777 volume permissions.
fsGroup: 65532
containers:
- name: jammi-server
# Pin-advice: `:latest` is re-pointed by every `v*` release tag.
# Pin an exact `:vX.Y.Z` tag for reproducible deploys.
image: ghcr.io/f-inverse/jammi-ai-server:latest
ports:
- { containerPort: 8081, name: flight }
- { containerPort: 8080, name: health }
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
envFrom:
# JAMMI_AUDIT_MASTER_KEY, JAMMI_CATALOG__POSTGRES__URL,
# JAMMI_BROKER__JET_STREAM__URL -- no Secret manifest ships in
# git; create it out-of-band (see the README).
- secretRef: { name: jammi-server-secrets, optional: false }
volumeMounts:
- { name: config, mountPath: /etc/jammi, readOnly: true }
- { name: scratch, mountPath: /var/lib/jammi }
volumes:
- name: config
configMap: { name: jammi-server-config } # the non-secret knobs only
- name: scratch
emptyDir: {} # local scratch only -- the catalog/broker/result-root carry the durable state
apiVersion: v1
kind: Service
metadata:
name: jammi-server
spec:
# No `type:` -> ClusterIP (the default): cluster-internal only, reachable
# from other pods/Services on this cluster, not from outside it -- pair
# with an Ingress/Gateway and a TLS terminator to reach it externally.
selector: { app: jammi-server }
ports:
- { name: flight, port: 8081, targetPort: 8081 }
- { name: health, port: 8080, targetPort: 8080 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- JAMMI_AUDIT_MASTER_KEY, JAMMI_CATALOG__POSTGRES__URL and
# JAMMI_BROKER__JET_STREAM__URL come from the Secret this Deployment's
# envFrom names, never from this ConfigMap.
[storage]
result_root = "s3://jammi-results/prod"
[storage.cloud.s3]
region = "us-east-1"
[lease]
duration_secs = 30
heartbeat_secs = 10
[distributed]
# Read at the submit edge only (this query tier is where jobs are
# enqueued; a compute pod never reads it): admits a `world_size` up to the
# compute pod's two devices. A cross-pod `Peer` gang needs this bound
# raised to the target world size here, and at least that many compute
# pods able to hold a rank.
max_world_size = 2
[server]
services = ["core", "event", "eval"]
[worker]
enabled = false
jammi-server-secrets is a Secret carrying the same env keys the Compose
and bare-env forms above use — JAMMI_CATALOG__POSTGRES__URL,
JAMMI_BROKER__JET_STREAM__CREDENTIALS__FILE (or __URL),
JAMMI_AUDIT_MASTER_KEY — mounted as env vars, never baked into the
ConfigMap. No Secret manifest ships in git; create it out-of-band, once per
cluster namespace:
kubectl -n <namespace> create secret generic jammi-server-secrets \
--from-literal=JAMMI_AUDIT_MASTER_KEY=<...> \
--from-literal=JAMMI_CATALOG__POSTGRES__URL=<...> \
--from-literal=JAMMI_BROKER__JET_STREAM__URL=<...>
never in git.
What kube-smoke proves. Against the ci overlay on a real kind
cluster: readiness (kubectl rollout status), get_server_info().broker == "jet_stream", the Postgres sources table’s row count via psql against
the postgres StatefulSet, one-hop image identity — every pod’s
containerStatuses[].imageID traces back to the image kind loaded, never
a registry pull — and a rollout restart after which the new pod still
sees the source the pod it replaced registered. What it does NOT prove:
durability across that restart — the scratch volume is an emptyDir, so the
Postgres catalog and the JetStream broker, not the pod’s local disk, carry
the state a fresh pod recovers.
Shape D — disaggregated
Artifact: the query tier above (Shape C’s Deployment) unchanged, plus a
GPU-scheduled StatefulSet compute tier and a single-replica CPU scheduler
Deployment, both running the SAME image family, scheduled separately.
Running jobs is not a service tier (see Service
tiers): whether a process claims and
executes the jobs it accepted is [worker] enabled. Every query-tier
replica runs [worker] enabled = false (JAMMI_WORKER__ENABLED=false) —
it still mounts core/event/eval and accepts every submission — and
both compute-tier roles below run [worker] enabled = true
(JAMMI_WORKER__ENABLED=true) so only they run the job worker’s claim
loop against the shared catalog. Their kinds differ: the compute
StatefulSet’s pods claim ["fine_tune", "graph_fine_tune", "context_predictor"], the scheduler Deployment claims ["fine_tune", "graph_fine_tune"] only — context_predictor has no placed arm, so
listing it on the CPU scheduler pod would train it there instead of on a
device. [server] services = [] on both — a pure compute/scheduler node
serves no query-tier gRPC.
Two compute-tier roles, one config knob. Whether a process hosts a
Ballista scheduler or executor (or neither) is [ballista]
(scheduler_bind / executor, see Configuration) —
a process with neither role runs exactly as it always has:
- The scheduler is ONE dedicated single-replica
Deployment(jammi-server-scheduler):[ballista] scheduler_bindset, no[ballista.executor], CPU image. It claims a training job and PLACES it — as one Ballista task — on a registered compute-pod executor; when no executor is registered yet it claims and runs the job in-process instead (byte-identical either way, per device kind), since it is also a plain worker-enabled fleet member. A third arm: when a live registered executor exists but none of its own devices lists the plan’s device kind (a row a dead executor left behind is not live and never counts), the submission is refused typed BEFORE it ever reaches the scheduler — the row is leftrunningfor reclaim (an attempt spent), never run in-process on the claiming pod. - The compute tier’s
StatefulSetpods (jammi-server-compute) each host a Ballista EXECUTOR ([ballista.executor]pointed at the scheduler’s Service) alongside their own[worker] enabled = trueclaim loop: a pod claims and runs a job in-process exactly like the scheduler can, or accepts a gang the scheduler placed on it.GangExec’sworldis informational only — topology is decided on the pod that actually runs the body, from its OWN[worker] local_ranks, never from the submitter’s.
Placement is decided BEFORE topology, for every fine_tune/
graph_fine_tune attempt a scheduler-role process claims (any process
whose HostAdmission exposes a placement submitter): it always attempts
to place the whole job as one Ballista task on a registered compute-pod
executor, regardless of W versus [worker] local_ranks. Only the pod
that ends up running the job’s coordinator body — the placed executor, or
the claiming process itself when no submitter seam exists or no other
executor is registered — decides Single/Local/Peer from its OWN
local_ranks: this overlay admits single-pod gangs (W ≤ 2, Local on
one pod’s two devices); a cross-pod Peer gang of world W needs W > local_ranks, max_world_size ≥ W on the submit edge (base/jammi.toml — the key is read
only where jobs are enqueued), and at least W compute pods able to hold a rank (the coordinator’s
included) — the submitting host moves to an Awaiting holder state for
the whole placement (it runs no compute meanwhile, but can still serve a
RunRank session). Placement always excludes a task’s own submitter: a
claimant’s host is never bound its own gang, so a lone compute pod placing
its own claim would deadlock against itself — this is why the scheduler is
a SEPARATE role rather than “whichever compute pod claims first places its
own siblings.”
Each jammi-server-compute pod’s peer_advertise is its own stable DNS
name under the headless Service
(<pod>.jammi-server-compute.<namespace>.svc.cluster.local,
publishNotReadyAddresses: true so a rank can dial a sibling that is still
warming), the property a plain Deployment’s churning pod names cannot
hold; its Ballista advertise_host is the SAME per-pod name, since the
scheduler must dial the executor back on the identical stable address.
Both compute-tier roles carry terminationGracePeriodSeconds: 600
— SIGTERM drains (the in-flight training job finishes, every epoch bundle
lands) and SIGKILL follows the grace; SIGINT, or jammi-server release from
a preStop hook, RELEASES — on a CONFIRMED release (exit 0), the job’s
lease is handed back at once and any other replica claims it within one
idle poll at no attempt cost; a DEGRADED release (exit 3) does not cost an
attempt universally, and its per-lease outcome depends on which determinant
degraded (see the RELEASE breakdown in deploy/kubernetes/README.md and
deploy-server.md below — never assume the CONFIRMED cost here for a
degraded exit). The grace must cover one epoch’s wall time; on spot
capacity use RELEASE. DRAIN on an executor pod additionally stops Ballista
task admission at once — the executor reports Terminating to the scheduler
the instant DRAIN begins, before the in-flight worker job is joined, and a
terminating executor is never bound; a gang the pod is still dialled with
inside its grace is refused before any claim transfer — but waits for any
in-flight placed gang before the process itself stops — only
RELEASE tears the executor down immediately. The operative rule, the
rollout arithmetic and both preStop recipes are in
deploy/kubernetes/README.md (“Shutdown: DRAIN and RELEASE”); the modes
themselves are in Shutdown.
The compute tier is a StatefulSet — each rank’s peer address (and
Ballista advertise_host) must stay stable across a pod restart, which a
Deployment’s churning pod names cannot hold. This overlay is validated by
kubeconform only — CI has no GPU node.
# jammi-server-compute ships as a StatefulSet, not a Deployment: each rank's
# peer address must stay stable across a pod restart for `instances.peer_addr`
# gang membership (`[server] peer_advertise`, DESIGN.md §7) -- a Deployment's
# pod names churn on every reschedule and cannot serve as that identity. The
# headless Service (service-compute-headless.yaml) is the DNS half of the
# same fact: ranks dial each other directly on the peer listener
# (`peer_advertise`), never through a load balancer or a virtual IP.
#
# `podManagementPolicy: Parallel`: ranks assemble by catalog membership
# (`instances` rows, DESIGN.md §7), never by ordinal start order, so no rank
# waits on a lower-ordinal sibling to become Ready before it starts.
#
# `nvidia.com/gpu: 2` matches `[worker] local_ranks = 2` in
# jammi-compute.toml: one device per local rank (`[gpu] devices = [0, 1]`).
#
# Each pod ALSO registers as a Ballista executor with the scheduler
# Deployment (deployment-scheduler.yaml): `ballista-flt`/`ballista-grpc`
# below are the shuffle/task listeners `[ballista.executor]`
# (jammi-compute.toml) binds, advertised at this same pod's stable DNS name
# (`JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST` below) -- a training job
# claimed by the scheduler pod is PLACED onto whichever of these pods the
# scheduler's device-aware policy picks; a training job claimed by one of
# these pods itself runs here in-process.
#
# kubeconform-validated only -- no GPU node is available in CI, so this
# StatefulSet never runs a real pod in the kind smoke.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: jammi-server-compute
spec:
serviceName: jammi-server-compute
replicas: 2
podManagementPolicy: Parallel
updateStrategy:
type: RollingUpdate
selector:
matchLabels: { app: jammi-server-compute }
template:
metadata:
labels: { app: jammi-server-compute }
spec:
# DRAIN on SIGTERM finishes the in-flight job; RELEASE (SIGINT /
# `jammi-server release` via preStop) hands it to a successor at once.
# 600 s = cluster-autoscaler `--max-graceful-termination-sec` default; a
# larger value is honoured only by rollouts/`kubectl delete` — raise both
# together. The operative rule and the rollout arithmetic are in the
# README ("Shutdown: DRAIN and RELEASE").
terminationGracePeriodSeconds: 600
nodeSelector:
gpu-node-pool: "true" # your cluster's own GPU node label
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
containers:
- name: jammi-server
# amd64 only. Pin-advice: `:latest` is re-pointed by every `v*`
# release tag; pin an exact `:vX.Y.Z` tag for reproducible
# GPU-node deploys.
image: ghcr.io/f-inverse/jammi-ai-server-cu12:latest
resources:
limits: { nvidia.com/gpu: 2 }
ports:
- { containerPort: 8081, name: flight }
- { containerPort: 8080, name: health }
- { containerPort: 9000, name: peer }
- { containerPort: 50051, name: ballista-flt }
- { containerPort: 50052, name: ballista-grpc }
env:
# The downward API's pod-field env source
# (EnvVarSource.fieldRef; `metadata.name` / `metadata.namespace`
# resolve to THIS pod's own values, not a template string).
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: POD_NAMESPACE
valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
# Kubernetes expands `$(VAR)` in an `env[].value` string only for
# a variable name declared EARLIER in this same list (the
# `EnvVar.value` field doc: "Variable references $(VAR_NAME) are
# expanded using the previously defined environment variables in
# the container and any service environment variables" --
# https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#envvar-v1-core)
# -- POD_NAME/POD_NAMESPACE above must stay ordered before this
# key. This is the pod's stable identity for gang membership: the
# ordinal-indexed DNS name the headless Service publishes
# (`<pod>.jammi-server-compute.<namespace>.svc.cluster.local`),
# never a virtual IP. `[server] peer_bind` (the listen side, the
# same on every replica) lives in jammi-compute.toml instead --
# one non-secret knob file for the shared value, env only for the
# per-pod identity that file cannot express.
- name: JAMMI_SERVER__PEER_ADVERTISE
value: "$(POD_NAME).jammi-server-compute.$(POD_NAMESPACE).svc.cluster.local:9000"
# The SAME per-pod stable DNS name, host only (no port -- this
# key is `[ballista.executor] advertise_host`, jammi-compute.toml,
# a bare host the scheduler dials back on the flight/grpc ports
# THAT config table already names).
- name: JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST
value: "$(POD_NAME).jammi-server-compute.$(POD_NAMESPACE).svc.cluster.local"
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
envFrom:
# Same catalog/broker Secret as the query tier.
- secretRef: { name: jammi-server-secrets, optional: false }
volumeMounts:
- { name: config, mountPath: /etc/jammi, readOnly: true }
- { name: scratch, mountPath: /var/lib/jammi }
volumes:
- name: config
configMap: { name: jammi-compute-config }
- name: scratch
# The cu12 image also keeps CUDA_CACHE_PATH under /var/lib/jammi
# alongside HF_HOME -- local scratch only, no durable state here.
emptyDir: {}
# Headless (clusterIP: None): ranks resolve each other by pod DNS name
# (`<pod>.jammi-server-compute.<namespace>.svc.cluster.local`, the value
# `[server] peer_advertise` is set to per-pod in statefulset-compute.yaml's
# `env`) and dial that name directly on the peer port -- never a virtual IP,
# never through this Service acting as a load balancer.
#
# `publishNotReadyAddresses: true`: the peer/gang listener is bound and
# already serving `PeerService`/`GangService` connections as soon as
# `OssServer::bind` returns (`crates/jammi-server/src/runtime.rs`), which
# happens BEFORE the process's own `/readyz` reports ready --
# `/readyz` gates the catalog ping and, when `[server] preload_models` is
# set, model preload (`docs/guide/src/deploy-server.md`'s "Preloading
# models": the claim loop "waits at its gate with its `workers.state` row
# reading `warming`" until then). A coordinator only dials a peer AFTER it
# claims a job, i.e. after its OWN `/readyz` is ready, but the PEER it
# dials may still be warming. Without this flag a not-yet-ready pod has no
# DNS record on a headless Service (the default EndpointSlice/DNS behaviour
# publishes only Ready pod addresses), so the coordinator would get NXDOMAIN
# resolving a peer whose `GangService` is already live and reachable.
apiVersion: v1
kind: Service
metadata:
name: jammi-server-compute
spec:
clusterIP: None
publishNotReadyAddresses: true
selector: { app: jammi-server-compute }
ports:
- { name: flight, port: 8081, targetPort: 8081 }
- { name: health, port: 8080, targetPort: 8080 }
- { name: peer, port: 9000, targetPort: 9000 }
- { name: ballista-flt, port: 50051, targetPort: 50051 }
- { name: ballista-grpc, port: 50052, targetPort: 50052 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- see base/jammi.toml's header.
[storage]
result_root = "s3://jammi-results/prod"
[storage.cloud.s3]
region = "us-east-1"
[lease]
duration_secs = 30
heartbeat_secs = 10
[gpu]
# The ordered device list this pod's ranks place on: rank i on devices[i].
# devices[0] must equal `device` (unset here, default 0), so no separate
# `device` key is needed. Two entries for the shape's two local ranks below.
devices = [0, 1]
# This pod also hosts a Ballista EXECUTOR -- it registers with the
# scheduler Deployment's Service and accepts a PLACED training job (one
# `GangExec` task) alongside the jobs it claims and runs on its own,
# in-process. One task slot: a placed gang and this pod's own in-process
# claim both take the SAME `HostAdmission` job slot (this pod coordinates
# one placed task at a time), so a second bound task is refused typed at
# the slot rather than left to contend for `[gpu] devices` capacity.
[ballista.executor]
scheduler_address = "jammi-server-scheduler:50050"
bind = "0.0.0.0:50051"
grpc_bind = "0.0.0.0:50052"
# The per-pod stable DNS name (statefulset-compute.yaml's downward-API
# `JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST`, same construction as
# `peer_advertise` below) -- never set here, since every replica shares this
# one ConfigMap and the value differs per pod.
task_slots = 1
# Pure compute node: mounts no query-tier gRPC service, only claims and runs
# queued jobs (in-process or as a placed Ballista task).
[server]
services = []
# This pod's own peer/gang listener, identical on every replica of this
# StatefulSet: "0.0.0.0:PORT" accepts a connection on any interface. The
# per-pod dial-in address other replicas use is `peer_advertise`, set from
# the downward API in statefulset-compute.yaml's `env` -- never here, since
# every replica shares this one ConfigMap and `peer_advertise` differs per
# pod.
peer_bind = "0.0.0.0:9000"
[worker]
enabled = true
# The training kinds; "all" would also claim every other compiled kind.
kinds = ["fine_tune", "graph_fine_tune", "context_predictor"]
# One rank per [gpu] devices entry: a claimed job with world_size <= 2 runs
# both ranks in this pod over a Local gang (no peer dial); a wider job makes
# this pod rank 0 of a Peer gang whose other rank is the StatefulSet's other
# replica.
local_ranks = 2
# The Ballista scheduler role, ONE dedicated single-replica Deployment: it
# hosts `[ballista] scheduler_bind` (claims and PLACES a training job onto
# a registered compute-pod executor) and `[worker] enabled = true` for the
# training kinds
# (so it can also claim and run one in-process, K4, when placement is not
# available -- e.g. no compute executor is registered yet). CPU image: this
# pod never itself trains on a device, it only decodes/re-encodes plans and
# assembles gangs.
#
# A plain (non-headless) Service in front of it is enough: a single replica
# behind a stable Service DNS name (`jammi-server-scheduler`) survives a pod
# restart exactly as well as a StatefulSet's ordinal identity would, since
# there is only ever one ordinal.
apiVersion: apps/v1
kind: Deployment
metadata:
name: jammi-server-scheduler
spec:
replicas: 1
selector:
matchLabels: { app: jammi-server-scheduler }
template:
metadata:
labels: { app: jammi-server-scheduler }
spec:
# Same DRAIN/RELEASE shutdown contract as every other role (README
# "Shutdown: DRAIN and RELEASE"); this pod holds no GPU, so its own
# grace only needs to cover an in-flight in-process claim, never a
# placed gang's device work (that grace is the compute overlay's).
terminationGracePeriodSeconds: 600
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
containers:
- name: jammi-server
# CPU image: the scheduler role decodes/encodes plans and places
# tasks, it never itself runs a device kernel.
image: ghcr.io/f-inverse/jammi-ai-server:latest
ports:
- { containerPort: 8081, name: flight }
- { containerPort: 8080, name: health }
- { containerPort: 9000, name: peer }
- { containerPort: 50050, name: ballista-sched }
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
envFrom:
# Same catalog/broker Secret as every other role -- one shared
# cluster, one catalog.
- secretRef: { name: jammi-server-secrets, optional: false }
volumeMounts:
- { name: config, mountPath: /etc/jammi, readOnly: true }
- { name: scratch, mountPath: /var/lib/jammi }
volumes:
- name: config
configMap: { name: jammi-scheduler-config }
- name: scratch
emptyDir: {}
# No `type:` -> ClusterIP: reachable from the compute StatefulSet's pods
# (they dial `jammi-server-scheduler:50050` as their `[ballista.executor]
# scheduler_address`, and this Service's stable DNS name doubles as this
# pod's `[server] peer_advertise`, jammi-scheduler.toml), never externally.
apiVersion: v1
kind: Service
metadata:
name: jammi-server-scheduler
spec:
selector: { app: jammi-server-scheduler }
ports:
- { name: flight, port: 8081, targetPort: 8081 }
- { name: health, port: 8080, targetPort: 8080 }
- { name: peer, port: 9000, targetPort: 9000 }
- { name: ballista-sched, port: 50050, targetPort: 50050 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- see base/jammi.toml's header.
[storage]
result_root = "s3://jammi-results/prod"
[storage.cloud.s3]
region = "us-east-1"
[lease]
duration_secs = 30
heartbeat_secs = 10
[ballista]
# This pod hosts the ONE Ballista scheduler: it claims a training job and
# PLACES it on a registered compute-pod executor (configuration.md's
# semantics: this table's presence makes a process a scheduler). No
# `[ballista.executor]` here -- this pod never itself registers as an
# executor, so a task it places can never be bound back to it (a task's own
# submitter is excluded from placement in any case).
scheduler_bind = "0.0.0.0:50050"
# This pod claims the training kinds a placed run handles (`fine_tune`,
# `graph_fine_tune`) and places each claimed job on a compute executor; while
# no compute executor is registered it runs the job in-process, exactly like
# any other worker-enabled process (the bytes are identical either way).
# `context_predictor` has no placed arm, so it is left to the compute pods:
# listing it here would train it on this CPU pod.
[worker]
enabled = true
kinds = ["fine_tune", "graph_fine_tune"]
# This pod's own peer/gang listener -- needed because a training job this
# pod claims and runs in-process (no placement available) may still assemble
# a multi-host gang across the compute StatefulSet's pods.
[server]
peer_bind = "0.0.0.0:9000"
# The stable Service DNS name this pod is always reachable at (a single
# replica behind a normal ClusterIP Service never needs a downward-API pod
# name the way the compute StatefulSet's per-pod `peer_advertise` does).
peer_advertise = "jammi-server-scheduler:9000"
# Pure scheduler node: mounts no query-tier gRPC service.
services = []
Both :latest tags are re-pointed by every v* release tag (never by a
prerelease); the CPU :latest can additionally be re-pointed to the current
main by a manual build-and-push-main dispatch. Pin an exact :vX.Y.Z
tag for reproducible GPU-node deploys.
Very high scale, specialized GPU pools, and a split compliance posture (query tier vs. training tier on separate node pools / network policies) are the shapes this topology serves.
Beyond-one-node retrieval
A query-tier replica answers Search over a table whose ANN index segments
it does not all hold by fanning the query out to the replicas that own
them: each owner searches its segments and returns (row_id, distance)
hits — never vectors — and the coordinator merges them under the same total
order a single node merges its own segments with (the distributed data
plane; see Security Posture for
the I-PEER invariant and Operability
for the failure ladder). Three facts fix the shape:
- The default is
AllLocal. With[server] peer_bindunset — every shape above — every segment is this replica’s, there is no third listener, and the search is exactly the single-node search (the same kernels, the same bytes, the same exact-read count at every segment count). Nothing on this page changes until a deployment opts in. peer_bindmakes a replica an owner (and a gang admission member). Setting[server] peer_bind(JAMMI_SERVER__PEER_BIND) opens the internal listener serving bothPeerService(segment search) andGangService(multi-host training-run admission — see Security Posture) on that replica. Bind it on a private interface behind network policy / mTLS from the runtime — its clients are other jammi coordinators and it authenticates nothing itself.- Precondition: a shared, replica-readable
result_root. A segment an owner serves must be a bundle every replica can reach:[storage] result_rooton an object store every replica reads (a localartifact_diris one node’s). Placement — which replica owns which segment — is derived at query time from the live replica ring ([server] peer_advertise+ the catalog’sinstancesrows, rendezvous- hashed over(table, segment)), never declared; the membership half of that ring is the compute-tier substrate’s (peer_advertise,instances.peer_addr), and until it lands a library process supplies an explicitStaticPlacementthroughInferenceSession::open_with_placement. Batch builders (the neighbor graph, eval) never fan out: they load the whole table’s segment set on the building replica. - Membership is judged fresh under a margin, pruned only well beyond it.
A row missing its heartbeat for
2 × lease(instance_liveness_margin) is no longer a fresh member; it is not actually deleted until3 × lease(instance_prune_window) — strictly beyond the margin, so a merely-stale member still has its row when the lease keeper’s own reregister lands. A process whose row WAS pruned during a transient outage (an outage longer than the window) rejoins the ring on its very next successful heartbeat, with no restart: the keeper’s reregister re-upserts the whole membership tuple (theinstancesrow and, if this process runs a claim loop, itsworkersrow) in one transaction.instances.result_rootrecords the configured root SPELLING verbatim, but neitherlist_gang_members(the gang-membership listing verb, contractfeat_500-C-U5b-1a§12) nor this ring consults it in this unit — root identity across spellings, and any membership predicate built on it, is a separate, not-yet-built unit. Were root-string equality ever made part of a membership predicate here, it would still be necessary, never sufficient, for shared storage — the attestation VERIFY a later unit owns is what establishes sufficiency.
A REFRESHED table’s Mixed arm is not version-aware. The single-node
(AllLocal, every segment this replica’s own) search path always resolves a
versioned table’s CURRENT version before searching it. The multi-node
Mixed arm — reached only when peer_bind is set and this table’s segments
span more than one replica — does not: it plans off list_index_segments’
flat, unversioned segment set, the same limitation the single-node path
carried before its own version-aware resolution was added. If a Shape D
deployment places a table refresh_embeddings has since published a new
version of across more than one owning replica, a Search served through
peers can surface rows from a version older than the table’s current one;
keep a refreshed table’s segments on a single owning replica (or force-local
it) until this closes.
The jammi-server probe subcommand
Every shape above that runs jammi-server — B, C, D — uses the same
readiness mechanism: jammi-server probe [--url URL] [--config PATH] [--timeout-secs N] GETs /readyz once and exits 0 on HTTP 200, 1
otherwise (never following a redirect), printing the failure status and body
to stderr. Without --url, the target derives from the resolved [server] health_listen through the identical config-resolution chain serve uses.
It is a generic CLI a supervisor drives from a subprocess exit code — Compose’s
HEALTHCHECK, systemd’s ExecStartPost=, Nomad’s check { type = "script" }
— the same shape as Postgres’s pg_isready. Kubernetes needs none of this:
its readinessProbe.httpGet already speaks HTTP directly against /readyz.
What the published images can and cannot do
The published CPU image (ghcr.io/f-inverse/jammi-ai-server) is built with
cargo build --features jammi-server/jetstream-broker,jammi-server/storage-cloud
— nothing else. GetServerInfo.features on that image therefore reports
["jetstream-broker"], and storage_backends reports the schemes
storage-cloud pulls in (s3, r2, gs, azure, alongside the always-on
file/memory).
A Postgres catalog ([catalog.postgres], Shapes B/C/D above) works on
this image unconditionally: sqlx’s Postgres driver is not gated behind any
cargo feature, so it is not a features entry at all — it is simply always
present.
The separate postgres cargo feature (datafusion-table-providers/postgres)
is a different capability — querying a remote Postgres database as a
federated SQL source, not the catalog backend — and is not compiled
into the published image. It would appear in features as "postgres"
only on a custom build that opts into it explicitly; do not read this
image’s Postgres-catalog support as evidence that source federation is
available.
Node architecture. The CPU image’s generic tags (ghcr.io/f-inverse/jammi-ai-server:latest/:vX.Y.Z/:vX.Y
and their sha-<sha> equivalents) are a multi-arch index — linux/amd64 and
linux/arm64 — so Shapes B/C’s query tier and Shape D’s disaggregated query
tier can schedule onto either an amd64 or an arm64 node pool without a
per-arch tag; docker pull/Kubernetes resolve the right member
automatically. Shape D’s GPU compute tier is unaffected by this: the CUDA
image (-cu12) is linux/amd64 only, so the GPU node pool stays amd64. The
same CPU image name’s self-contained tags (:selfcontained,
:selfcontained-sha-<sha>) are also linux/amd64 only — never schedule
those onto an arm64 node pool.