jammi_server/runtime.rs
1//! `OssServer` — the single orchestration entry-point for the OSS
2//! `jammi-server` binary.
3//!
4//! One `OssServer` wires together:
5//!
6//! - the engine [`InferenceSession`] (catalog, mutable tables, broker)
7//! - a [`SessionStore`] shared between Flight SQL and the gRPC services
8//! - the Axum side-channel router (`/healthz`, `/readyz`, `/metrics`)
9//! - one Tonic server hosting `FlightSqlService + CatalogService +
10//! TriggerService` on a single port
11//! - graceful shutdown wired to SIGINT/SIGTERM via a
12//! [`tokio::sync::broadcast`] so every component drains in parallel
13//!
14//! The structure is intentionally flat: no `runtime/` directory, no
15//! per-component sub-modules. When a second binary materialises the same
16//! shape can be reused — the orchestration is the engine of last resort
17//! and earns its keep by being grep-able in one place.
18
19use std::future::Future;
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use arrow_flight::flight_service_server::FlightServiceServer;
24use async_trait::async_trait;
25use axum::routing::get;
26use axum::Router;
27use datafusion::execution::context::SessionContext;
28use datafusion_flight_sql_server::service::FlightSqlService;
29use jammi_ai::session::InferenceSession;
30use jammi_db::config::JammiConfig;
31use tokio::net::TcpListener;
32use tokio::signal;
33use tokio::sync::broadcast;
34use tonic::transport::server::TcpIncoming;
35use tonic::transport::Server;
36use tonic_web::GrpcWebLayer;
37use tower::Layer;
38
39use crate::error::fallback_handler;
40use crate::flight::TenantBoundProvider;
41use crate::grpc::audit::AuditServer;
42use crate::grpc::catalog::CatalogServer;
43use crate::grpc::embedding::EmbeddingServer;
44use crate::grpc::eval::EvalServer;
45use crate::grpc::inference::InferenceServer;
46use crate::grpc::pipeline::PipelineServer;
47use crate::grpc::proto::audit::audit_service_server::AuditServiceServer;
48use crate::grpc::proto::catalog::catalog_service_server::CatalogServiceServer;
49use crate::grpc::proto::embedding::embedding_service_server::EmbeddingServiceServer;
50use crate::grpc::proto::eval::eval_service_server::EvalServiceServer;
51use crate::grpc::proto::inference::inference_service_server::InferenceServiceServer;
52use crate::grpc::proto::pipeline::pipeline_service_server::PipelineServiceServer;
53#[cfg(feature = "train")]
54use crate::grpc::proto::training::training_service_server::TrainingServiceServer;
55use crate::grpc::proto::trigger::trigger_service_server::TriggerServiceServer;
56use crate::grpc::session::{SessionIdTenantResolver, SessionStore, TenantResolver};
57#[cfg(feature = "train")]
58use crate::grpc::training::TrainingServer;
59use crate::grpc::trigger::TriggerServer;
60use crate::grpc_web_trailers::GrpcWebTrailersLayer;
61use crate::metrics_layer::MetricsLayer;
62use crate::routes::health::{self, MetricsRegistry};
63use crate::tenant_resolver_layer::TenantResolverLayer;
64use crate::tiers::{ServiceTier, TierSet};
65
66/// Errors `OssServer::run` can surface to the binary's `main`.
67#[derive(Debug, thiserror::Error)]
68pub enum ServerError {
69 #[error("config error: {0}")]
70 Config(String),
71 #[error("service tier: {0}")]
72 Tier(#[from] crate::tiers::TierError),
73 #[error("engine init: {0}")]
74 Engine(#[from] jammi_db::error::JammiError),
75 #[error("metrics registry: {0}")]
76 Metrics(#[from] prometheus::Error),
77 #[error("transport: {0}")]
78 Transport(#[from] tonic::transport::Error),
79 #[error("io: {0}")]
80 Io(#[from] std::io::Error),
81 #[error("addr parse: {0}")]
82 AddrParse(#[from] std::net::AddrParseError),
83}
84
85/// A readiness probe: pings whatever resource readiness depends on. The
86/// implementation lives behind a trait so tests can substitute a stub
87/// that returns deterministic outcomes (the substrate session itself
88/// has more moving parts than a probe test cares about).
89#[async_trait]
90pub trait ReadinessCheck: Send + Sync {
91 /// `Ok(())` means the underlying resource responded; `Err(s)` is a
92 /// human-readable failure reason surfaced in the `/readyz` body.
93 async fn check(&self) -> Result<(), String>;
94}
95
96/// Wrapper that holds the active [`ReadinessCheck`] behind an `Arc` so
97/// Axum can share it across handlers via `State`.
98pub struct ReadinessProbe {
99 inner: Arc<dyn ReadinessCheck>,
100}
101
102impl ReadinessProbe {
103 pub fn new(inner: Arc<dyn ReadinessCheck>) -> Self {
104 Self { inner }
105 }
106
107 pub async fn check(&self) -> Result<(), String> {
108 self.inner.check().await
109 }
110}
111
112/// Readiness check backed by the engine's catalog backend. Delegates to
113/// [`jammi_db::catalog::Catalog::ping`], which issues a backend-native
114/// reachability probe (no transaction, no lock) and surfaces pool failures
115/// as [`jammi_db::catalog::backend::BackendError::Unavailable`].
116pub struct CatalogPingProbe {
117 session: Arc<InferenceSession>,
118}
119
120impl CatalogPingProbe {
121 pub fn new(session: Arc<InferenceSession>) -> Self {
122 Self { session }
123 }
124}
125
126#[async_trait]
127impl ReadinessCheck for CatalogPingProbe {
128 async fn check(&self) -> Result<(), String> {
129 self.session
130 .catalog()
131 .ping()
132 .await
133 .map_err(|e| e.to_string())
134 }
135}
136
137/// The OSS server instance. Constructed via [`Self::new`] and consumed
138/// by [`Self::run`]. Holds every long-lived dependency the binary
139/// orchestrates — bind addresses, the engine session, the shared
140/// SessionStore, the metrics registry, and the readiness probe.
141pub struct OssServer {
142 flight_addr: SocketAddr,
143 health_addr: SocketAddr,
144 session: Arc<InferenceSession>,
145 session_store: SessionStore,
146 metrics: Arc<MetricsRegistry>,
147 readiness: Arc<ReadinessProbe>,
148 tiers: TierSet,
149}
150
151impl OssServer {
152 /// Build an OSS server from `JammiConfig`. Validates the server
153 /// configuration up front (parses both bind addresses, rejects two
154 /// identical FIXED bind addresses — identical `:0` ephemeral addresses
155 /// are allowed, since each resolves to a distinct port at bind),
156 /// constructs the engine session
157 /// (catalog, mutable tables, broker), and prepares the shared
158 /// metrics registry and readiness probe.
159 pub async fn new(config: JammiConfig) -> Result<Self, ServerError> {
160 config
161 .server
162 .validate()
163 .map_err(|e| ServerError::Config(e.to_string()))?;
164 // Reject training timing that violates the worker invariants (heartbeat
165 // margin / non-zero poll) at construction, before the train tier spawns
166 // its worker.
167 config
168 .training
169 .worker_intervals()
170 .map_err(|e| ServerError::Config(e.to_string()))?;
171
172 let flight_addr: SocketAddr = config.server.flight_listen.parse()?;
173 let health_addr: SocketAddr = config.server.health_listen.parse()?;
174
175 // Resolve the mounted tier set before constructing the engine: a config
176 // that names an unknown tier or one whose feature is compiled out is a
177 // startup error, not a silent degrade.
178 let tiers = TierSet::from_config(&config.server.services)?;
179
180 // `open` (not `new`) registers the `annotate` query UDTF on the engine's
181 // DataFusion context — the Flight SQL surface needs it. It already returns
182 // an `Arc<InferenceSession>`.
183 let session = InferenceSession::open(config).await?;
184 let session_store = SessionStore::new();
185 let metrics = Arc::new(MetricsRegistry::new()?);
186 let readiness = Arc::new(ReadinessProbe::new(Arc::new(CatalogPingProbe::new(
187 Arc::clone(&session),
188 ))));
189
190 Ok(Self {
191 flight_addr,
192 health_addr,
193 session,
194 session_store,
195 metrics,
196 readiness,
197 tiers,
198 })
199 }
200
201 /// Shared handle to the metrics registry. Test fixtures and the
202 /// gRPC services use this to increment counters.
203 pub fn metrics(&self) -> Arc<MetricsRegistry> {
204 Arc::clone(&self.metrics)
205 }
206
207 /// Shared handle to the engine session. Useful in tests that want
208 /// to publish to a topic or read a mutable table while the server
209 /// is running.
210 pub fn session(&self) -> Arc<InferenceSession> {
211 Arc::clone(&self.session)
212 }
213
214 /// Override the readiness probe. Used by integration tests to make
215 /// `/readyz` deterministically return 503.
216 pub fn with_readiness(mut self, readiness: Arc<ReadinessProbe>) -> Self {
217 self.readiness = readiness;
218 self
219 }
220
221 /// Bind both listeners eagerly and return a [`BoundServer`] holding them
222 /// live. The gRPC + Flight SQL surface and the HTTP side-channel are each
223 /// bound here — so their ACTUAL addresses are known (the real ports even
224 /// when the config requested an ephemeral `:0`) before a single connection
225 /// is served, and neither port is released before
226 /// [`BoundServer::serve_with_shutdown`] serves on the same listeners. A
227 /// caller reads the resolved ports off the returned handle
228 /// ([`BoundServer::flight_addr`] / [`BoundServer::health_addr`]) while the
229 /// listeners stay bound, with no observable release-then-rebind window.
230 pub async fn bind(self) -> Result<BoundServer, ServerError> {
231 let health_router = self.build_health_router();
232 let health_listener = TcpListener::bind(self.health_addr).await?;
233 let health_addr = health_listener.local_addr()?;
234 let grpc = assemble_grpc_chain(self.build_grpc_chain())?.bind().await?;
235 Ok(BoundServer {
236 grpc,
237 health_listener,
238 health_addr,
239 health_router,
240 })
241 }
242
243 /// Drive the server until SIGINT / SIGTERM arrives. Both the HTTP
244 /// side-channel and the gRPC surface drain in parallel; the call
245 /// returns when both have stopped accepting new connections and
246 /// finished serving in-flight requests.
247 pub async fn run(self) -> Result<(), ServerError> {
248 self.bind()
249 .await?
250 .serve_with_shutdown(shutdown_signal())
251 .await
252 }
253
254 /// Variant of [`Self::run`] that accepts a caller-provided
255 /// shutdown future. Tests use this to drive deterministic
256 /// teardown.
257 pub async fn run_with_shutdown(
258 self,
259 shutdown: impl Future<Output = ()> + Send + 'static,
260 ) -> Result<(), ServerError> {
261 self.bind().await?.serve_with_shutdown(shutdown).await
262 }
263
264 fn build_health_router(&self) -> Router {
265 // Two sub-routers keep the State types separated — Axum requires
266 // every route in a Router to share the same State type, so the
267 // readiness handler and the metrics handler are merged here
268 // after each one's State is applied.
269 let readyz = Router::new()
270 .route("/readyz", get(health::readyz))
271 .with_state(Arc::clone(&self.readiness));
272 let metrics = Router::new()
273 .route("/metrics", get(health::metrics))
274 .with_state(Arc::clone(&self.metrics));
275 Router::new()
276 .route("/healthz", get(health::healthz))
277 .merge(readyz)
278 .merge(metrics)
279 .fallback(fallback_handler)
280 }
281
282 /// Assemble the engine's [`GrpcChain`] from this server's config and engine
283 /// session. Derives the event-tier trigger handles (only when the event
284 /// tier is mounted) and the engine-default `jammi-session-id` tenant
285 /// resolver — the OSS-cooperative multitenancy binder.
286 fn build_grpc_chain(&self) -> GrpcChain {
287 // The event tier (`TriggerService`) is mounted only when the deployment
288 // selected it; the handles are derived from the same engine session.
289 let trigger = self
290 .tiers
291 .contains(ServiceTier::Event)
292 .then(|| crate::TriggerHandles {
293 topic_repo: self.session.topic_repo(),
294 publisher: self.session.publisher(),
295 subscriber: self.session.subscriber(),
296 });
297 GrpcChain {
298 addr: self.flight_addr,
299 flight_ctx: self.session.context().clone(),
300 flight_binding: self.session.tenant_binding_arc(),
301 store: self.session_store.clone(),
302 trigger,
303 engine: Some(Arc::clone(&self.session)),
304 tiers: self.tiers.clone(),
305 metrics: Arc::clone(&self.metrics),
306 // The OSS binary binds tenants with the engine-default
307 // `jammi-session-id` resolver — the OSS-cooperative multitenancy
308 // behavior, now a first-class resolver rather than an implicit
309 // interceptor.
310 tenant_resolver: SessionIdTenantResolver::arc(self.session_store.clone()),
311 }
312 }
313}
314
315/// An [`OssServer`] with both listeners bound and held live — the eager-bind
316/// half of the serve seam. Returned by [`OssServer::bind`]. The gRPC + Flight
317/// SQL surface and the HTTP side-channel are already listening:
318/// [`Self::flight_addr`] / [`Self::health_addr`] report the ACTUAL bound
319/// addresses (the real ports even when the config requested `:0`), and neither
320/// port is released before [`Self::serve_with_shutdown`] serves on the very same
321/// listeners — there is no release-then-rebind window a concurrent binder could
322/// slip into.
323pub struct BoundServer {
324 grpc: BoundChain,
325 health_listener: TcpListener,
326 health_addr: SocketAddr,
327 health_router: Router,
328}
329
330impl BoundServer {
331 /// The ACTUAL address the gRPC + Flight SQL surface is bound to. When the
332 /// config requested `:0`, this is the ephemeral port the kernel assigned —
333 /// resolved at [`OssServer::bind`] and held.
334 pub fn flight_addr(&self) -> SocketAddr {
335 self.grpc.addr()
336 }
337
338 /// The ACTUAL address the HTTP side-channel (`/healthz`, `/readyz`,
339 /// `/metrics`) is bound to. When the config requested `:0`, this is the
340 /// ephemeral port the kernel assigned.
341 pub fn health_addr(&self) -> SocketAddr {
342 self.health_addr
343 }
344
345 /// Serve both halves on the already-bound listeners until `shutdown`
346 /// resolves. The HTTP side-channel and the gRPC surface drain in parallel;
347 /// the call returns when both have stopped accepting new connections and
348 /// finished serving in-flight requests.
349 pub async fn serve_with_shutdown(
350 self,
351 shutdown: impl Future<Output = ()> + Send + 'static,
352 ) -> Result<(), ServerError> {
353 // Fan out one shutdown signal to both servers. A `broadcast`
354 // channel gives every subscriber an independent receiver and
355 // does not require the futures to share lifetimes.
356 let (shutdown_tx, _) = broadcast::channel::<()>(1);
357 let mut shutdown_health_rx = shutdown_tx.subscribe();
358 let mut shutdown_grpc_rx = shutdown_tx.subscribe();
359 let shutdown_tx_for_signal = shutdown_tx.clone();
360 tokio::spawn(async move {
361 shutdown.await;
362 // Receivers may already be gone if the servers errored
363 // first; either way the broadcast send is best-effort.
364 let _ = shutdown_tx_for_signal.send(());
365 });
366
367 let BoundServer {
368 grpc,
369 health_listener,
370 health_addr,
371 health_router,
372 } = self;
373 tracing::info!(
374 address = %health_addr,
375 "HTTP side-channel listening (/healthz, /readyz, /metrics)"
376 );
377
378 let health_task = tokio::spawn(async move {
379 axum::serve(health_listener, health_router)
380 .with_graceful_shutdown(async move {
381 let _ = shutdown_health_rx.recv().await;
382 })
383 .await
384 .map_err(ServerError::from)
385 });
386
387 // Run both halves to completion. If either errors out we still
388 // wait for the other to drain — abandoning a running server
389 // mid-shutdown corrupts in-flight connections.
390 let grpc_result = grpc
391 .serve_with_shutdown(async move {
392 let _ = shutdown_grpc_rx.recv().await;
393 })
394 .await;
395 if grpc_result.is_err() {
396 let _ = shutdown_tx.send(());
397 }
398 let health_result = match health_task.await {
399 Ok(r) => r,
400 Err(join_err) => Err(ServerError::Io(std::io::Error::other(join_err.to_string()))),
401 };
402
403 grpc_result.and(health_result)
404 }
405
406 /// Serve both halves until SIGINT / SIGTERM arrives. The binary entry
407 /// point ([`OssServer::run`] and `main`) reaches graceful shutdown through
408 /// this.
409 pub async fn serve(self) -> Result<(), ServerError> {
410 self.serve_with_shutdown(shutdown_signal()).await
411 }
412}
413
414/// Everything [`serve_grpc_chain`] needs to mount the Tonic chain: the bind
415/// address, the Flight SQL context + tenant binding, the shared session store,
416/// the optional trigger handles and engine session, and the resolved tier set.
417///
418/// Grouped into one options object (rather than a long positional argument list)
419/// so callers name what they pass and the mount surface has one place to grow.
420/// `OssServer` builds this from the engine session; test fixtures construct it
421/// directly.
422pub struct GrpcChain {
423 /// Bind address for the combined gRPC + Flight SQL surface.
424 pub addr: SocketAddr,
425 /// Flight SQL session context.
426 pub flight_ctx: SessionContext,
427 /// Tenant binding the Flight SQL provider mutates per request.
428 pub flight_binding: jammi_db::tenant_scope::TenantBinding,
429 /// Session store shared between the `CatalogService` tenant trio (writers)
430 /// and the engine-default `SessionIdTenantResolver` (reader).
431 pub store: SessionStore,
432 /// Trigger handles — `Some` iff the event tier is mounted.
433 pub trigger: Option<crate::TriggerHandles>,
434 /// Engine session backing the engine-layer services — `None` for the
435 /// transport-only fixtures.
436 pub engine: Option<Arc<InferenceSession>>,
437 /// The tier set this chain mounts and advertises over `GetServerInfo`.
438 pub tiers: TierSet,
439 /// Shared metrics registry. The whole-server [`MetricsLayer`] holds it and
440 /// drives the substrate counters / latency histogram from the request path;
441 /// the Axum `/metrics` route reads the same registry to scrape it.
442 pub metrics: Arc<MetricsRegistry>,
443 /// The one tenant-binding resolver. Every engine service (and Flight SQL)
444 /// binds its request's tenant through THIS resolver via the async
445 /// [`crate::tenant_resolver_layer::TenantResolverLayer`] — the single binder,
446 /// no interceptor path. Production and the OSS binary pass
447 /// [`SessionIdTenantResolver::arc`] (the OSS-cooperative `jammi-session-id`
448 /// default); a downstream composing the seam passes its own authenticating
449 /// resolver to unify the composability seam with its BYO-auth seam. Only the
450 /// services `assemble_grpc_chain` itself builds are bound by it — downstream
451 /// services later [`AssembledChain::mount`]ed are NOT wrapped.
452 pub tenant_resolver: Arc<dyn TenantResolver>,
453}
454
455/// The engine's fully-assembled gRPC chain, ready for a downstream to mount
456/// additional services onto before serving.
457///
458/// Holds a [`tonic::service::Routes`] with the engine's services pre-added
459/// (Flight SQL + `CatalogService` + the tier/engine services, including
460/// `AuditService`) and any resource whose lifetime must span the serve loop (the
461/// embedded training worker). A downstream chains [`Self::mount`] (un-gated) or
462/// [`Self::mount_tenant_scoped`] (bound by the engine's single tenant resolver)
463/// to add its own services beside the engine's, then [`Self::serve`]s — or
464/// splits to compose one listener of its own: [`Self::into_layered_axum_router`]
465/// is the safe default (the engine's transport stack pre-applied, ready for
466/// [`axum::serve()`]), and
467/// [`Self::into_axum_router`] is the expert, layer-free split for nesting under a
468/// listener that already frames gRPC-web.
469///
470/// The transport layer stack (`accept_http1` + `MetricsLayer` +
471/// `GrpcWebTrailersLayer` + `GrpcWebLayer`) is applied by [`Self::serve`], not
472/// baked into the routes — see that method, [`Self::into_layered_axum_router`],
473/// and [`Self::into_axum_router`] for the seam contract each path honours.
474pub struct AssembledChain {
475 addr: SocketAddr,
476 routes: tonic::service::Routes,
477 mounted: Vec<String>,
478 // The metrics handle the `MetricsLayer` needs at serve time. Carried forward
479 // because the layer stack is deferred to `serve` — the outermost layer
480 // observes every request by method path.
481 metrics: Arc<MetricsRegistry>,
482 // The SAME `TenantResolverLayer` (holding the same `Arc<dyn TenantResolver>`)
483 // that wraps every engine service in `assemble_grpc_chain`. Retained
484 // (`#[derive(Clone)]`, a cheap `Arc` clone) so `mount_tenant_scoped` can wrap
485 // a downstream service through it too — the single-binder invariant holds:
486 // there is still exactly ONE resolver, never a second one forked off here.
487 tenant_resolver_layer: TenantResolverLayer,
488 // The embedded training worker the `train` tier owns, held RAII for the serve
489 // loop. Owned by the chain (not the assemble frame) so it survives the
490 // assemble→serve split; `serve` keeps it alive across the serve future and
491 // `into_axum_router` hands it onward in [`ChainParts`]. `#[cfg]`-gated so a
492 // serve-only build carries no worker field.
493 #[cfg(feature = "train")]
494 _train_worker: Option<jammi_ai::fine_tune::worker::EmbeddedWorker>,
495}
496
497/// The non-routing remainder of an [`AssembledChain`] after
498/// [`AssembledChain::into_axum_router`] splits the routes off: the resolved bind
499/// address, the mounted-service ledger (for the downstream's startup log), the
500/// engine metrics handle (so a single-listener downstream can re-apply the
501/// engine's [`MetricsLayer`] on its own listener), and the training-worker guard
502/// the downstream must keep alive for the lifetime of its own serve loop.
503pub struct ChainParts {
504 pub addr: SocketAddr,
505 pub mounted: Vec<String>,
506 pub metrics: Arc<MetricsRegistry>,
507 /// The embedded training worker guard. The downstream MUST hold this for the
508 /// lifetime of its serve loop — dropping it stops the worker and submitted
509 /// jobs stop running.
510 #[cfg(feature = "train")]
511 pub train_worker: Option<jammi_ai::fine_tune::worker::EmbeddedWorker>,
512}
513
514impl AssembledChain {
515 /// Mount a downstream service beside the engine's, **un-gated by the engine's
516 /// tenant resolver**. Delegates [`tonic::service::Routes::add_service`] (by
517 /// value, chainable) and records the service's `NamedService::NAME` in the
518 /// mounted ledger for the startup tracing line — so the ledger cannot drift
519 /// from what is actually mounted. Generic: the engine names no consumer. The
520 /// service inherits the transport layer stack [`Self::serve`] applies,
521 /// exactly as the engine's own services do — but NOT the tenant-binding
522 /// layer, so no `SessionTenant` extension is bound on its requests.
523 ///
524 /// Reach for this for a service that must run BEFORE a tenant is known — a
525 /// pre-auth handshake (e.g. a login/bootstrap verb) that a caller reaches
526 /// without a resolvable credential yet. Use [`Self::mount_tenant_scoped`]
527 /// instead for a service that wants the engine's resolved tenant bound
528 /// uniformly.
529 pub fn mount<S>(mut self, svc: S) -> Self
530 where
531 S: tonic::codegen::Service<
532 tonic::codegen::http::Request<tonic::body::Body>,
533 Error = std::convert::Infallible,
534 > + tonic::server::NamedService
535 + Clone
536 + Send
537 + Sync
538 + 'static,
539 S::Response: axum::response::IntoResponse,
540 S::Future: Send + 'static,
541 {
542 self.mounted.push(S::NAME.to_string());
543 self.routes = self.routes.add_service(svc);
544 self
545 }
546
547 /// Mount a downstream service beside the engine's, wrapped by the engine's
548 /// SAME single [`TenantResolverLayer`] every engine service is wrapped with
549 /// (retained on `self` from [`assemble_grpc_chain`] — there is still exactly
550 /// ONE `Arc<dyn TenantResolver>`, never a second resolver forked off here).
551 /// Per request, the wrapper resolves the tenant BEFORE the service's handler
552 /// runs: `Ok(scope)` binds the `SessionTenant` extension the handler reads
553 /// ([`TenantScope::Tenant`](crate::grpc::session::TenantScope::Tenant) →
554 /// `Some`, [`TenantScope::Global`](crate::grpc::session::TenantScope::Global)
555 /// → `None`) and calls it; `Err(status)` returns that status and the handler
556 /// NEVER runs — see [`crate::tenant_resolver_layer`] for the full per-request
557 /// contract. The wrapper forwards the inner service's `NamedService::NAME`,
558 /// so the mounted ledger and the proto route both stay keyed to the
559 /// service's own name, exactly as [`Self::mount`] records it.
560 ///
561 /// Reach for this so a downstream service gets `SessionTenant` bound
562 /// uniformly with every engine service, dropping its own per-handler
563 /// resolve. Use plain [`Self::mount`] instead for a service that must stay
564 /// un-gated (e.g. a pre-auth login/bootstrap handshake reached before any
565 /// tenant is known).
566 ///
567 /// **Do NOT** mount a service here that already binds its own
568 /// `SessionTenant` internally (or resolves tenant identity some other way)
569 /// — the two binds would race and the later one silently wins
570 /// (last-writer-wins on the request extension), which is exactly the
571 /// double-bind the engine's single-binder invariant exists to prevent. Wrap
572 /// a service with this method at most once, and never alongside a
573 /// self-resolving service.
574 ///
575 /// PRE-SPLIT ONLY: this method lives on [`AssembledChain`], before
576 /// [`Self::into_axum_router`] / [`Self::into_layered_axum_router`] split the
577 /// routes off. [`ChainParts`] does NOT carry the layer forward — a
578 /// downstream that splits to compose its own listener re-applies the
579 /// (already `pub`) [`TenantResolverLayer`] itself via [`tower::Layer::layer`]
580 /// before nesting its service, exactly as this method does internally.
581 pub fn mount_tenant_scoped<S, ResBody>(mut self, svc: S) -> Self
582 where
583 S: tonic::codegen::Service<
584 tonic::codegen::http::Request<tonic::body::Body>,
585 Response = tonic::codegen::http::Response<ResBody>,
586 Error = std::convert::Infallible,
587 > + tonic::server::NamedService
588 + Clone
589 + Send
590 + Sync
591 + 'static,
592 S::Future: Send + 'static,
593 ResBody: Default + 'static,
594 tonic::codegen::http::Response<ResBody>: axum::response::IntoResponse,
595 {
596 let scoped = self.tenant_resolver_layer.clone().layer(svc);
597 self.mounted.push(S::NAME.to_string());
598 self.routes = self.routes.add_service(scoped);
599 self
600 }
601
602 /// The bind address the engine resolved from config. The downstream serves here.
603 pub fn addr(&self) -> SocketAddr {
604 self.addr
605 }
606
607 /// The ledger of mounted service names, in mount order (engine's first, then
608 /// any the downstream added via [`Self::mount`]). Read for a startup log; the
609 /// ledger cannot drift from what is actually on the routes.
610 pub fn mounted(&self) -> &[String] {
611 &self.mounted
612 }
613
614 /// Bind the gRPC + Flight SQL listener eagerly and return a [`BoundChain`]
615 /// holding it live. The listener is opened HERE, so [`BoundChain::addr`]
616 /// reports the ACTUAL bound address — the real port even when the chain was
617 /// assembled at an ephemeral `:0` — and the port stays held with no release
618 /// window until [`BoundChain::serve_with_shutdown`] serves on the very same
619 /// listener. A caller that needs the resolved port before serving (a test
620 /// harness building a client, a downstream logging its startup address)
621 /// reads it off the bound handle rather than pre-binding-and-dropping a
622 /// throwaway listener.
623 ///
624 /// `nodelay` is set to match the [`tonic::transport::Server`] default the
625 /// addr-based serve path applies, so the served connections behave
626 /// identically to a chain served straight from a configured address.
627 pub async fn bind(self) -> Result<BoundChain, ServerError> {
628 let listener = TcpListener::bind(self.addr).await?;
629 let incoming = TcpIncoming::from(listener).with_nodelay(Some(true));
630 let addr = incoming.local_addr()?;
631 Ok(BoundChain {
632 incoming,
633 addr,
634 routes: self.routes,
635 mounted: self.mounted,
636 metrics: self.metrics,
637 #[cfg(feature = "train")]
638 _train_worker: self._train_worker,
639 })
640 }
641
642 /// Serve the assembled chain (engine core + any downstream-mounted services)
643 /// until `shutdown` resolves. A thin composition of [`Self::bind`] +
644 /// [`BoundChain::serve_with_shutdown`] — binds the listener, then serves on
645 /// it; the training-worker guard stays alive for the whole serve loop. The
646 /// transport layer stack is applied by [`BoundChain::serve_with_shutdown`].
647 pub async fn serve(
648 self,
649 shutdown: impl Future<Output = ()> + Send + 'static,
650 ) -> Result<(), ServerError> {
651 self.bind().await?.serve_with_shutdown(shutdown).await
652 }
653
654 /// Split into a plain, **LAYER-FREE** [`axum::Router`] (via
655 /// [`tonic::service::Routes::into_axum_router`]) plus the [`ChainParts`]
656 /// remainder — the EXPERT split, for a downstream that nests the engine's
657 /// gRPC routes UNDER a listener that ALREADY carries its own gRPC-web layer
658 /// stack. Most single-listener consumers want [`Self::into_layered_axum_router`]
659 /// instead (the safe default — see below).
660 ///
661 /// SEAM CONTRACT: the returned router carries NO transport layers. The
662 /// gRPC-web framing + trailer-repair + metrics layers are applied by
663 /// [`Self::serve`] on the serve path, NOT baked into the routes. A downstream
664 /// that serves this router on its OWN listener must therefore re-apply the
665 /// full stack itself — [`GrpcWebLayer`] + [`GrpcWebTrailersLayer`] + the
666 /// engine's [`MetricsLayer`] (via [`ChainParts::metrics`]) — or gRPC-web
667 /// clients break: a trailers-only error response would miss the in-body
668 /// trailer frame.
669 ///
670 /// PATH-SPECIFIC LAYERING: `accept_http1(true)` is a
671 /// [`tonic::transport::Server`] builder method and applies ONLY on the
672 /// [`Self::serve`] path — there is no `accept_http1` to call on the axum
673 /// path; HTTP/1 is implicit in [`axum::serve()`]. On axum, re-apply the layers
674 /// with `Router::layer` in inner→outer call order (axum runs the LAST
675 /// `.layer` call as the outermost service, the inverse of the tonic builder),
676 /// i.e. `.layer(GrpcWebLayer::new()).layer(GrpcWebTrailersLayer::new())
677 /// .layer(MetricsLayer::new(metrics))`. This is exactly what
678 /// [`Self::into_layered_axum_router`] does for you — reach for the layer-free
679 /// split only when your outer listener already frames gRPC-web (re-applying
680 /// here would double-frame).
681 ///
682 /// The router also carries a gRPC `unimplemented` fallback (from
683 /// `Routes`' default), so a composing consumer must nest it under a path
684 /// prefix or reconcile its own fallback, NOT blind-`.merge()` it.
685 ///
686 /// The downstream must hold [`ChainParts`] (specifically its training-worker
687 /// guard) alive for the lifetime of its own serve loop.
688 pub fn into_axum_router(self) -> (axum::Router, ChainParts) {
689 let router = self.routes.into_axum_router();
690 let parts = ChainParts {
691 addr: self.addr,
692 mounted: self.mounted,
693 metrics: self.metrics,
694 #[cfg(feature = "train")]
695 train_worker: self._train_worker,
696 };
697 (router, parts)
698 }
699
700 /// Split into a **layered** [`axum::Router`] plus the [`ChainParts`]
701 /// remainder — the SAFE DEFAULT for a downstream that composes ONE listener
702 /// of its own. The returned router is ready to hand to [`axum::serve()`]
703 /// DIRECTLY: it carries the engine's full transport contract, so the consumer
704 /// re-applies nothing.
705 ///
706 /// CANONICAL LAYER STACK: this applies the SAME stack [`Self::serve`] applies
707 /// — the whole-server [`MetricsLayer`] (outermost, observing every method
708 /// path) wrapping [`GrpcWebTrailersLayer`] (the trailers-only error repair)
709 /// wrapping [`GrpcWebLayer`] (gRPC-web framing) wrapping the routes. axum runs
710 /// the LAST `.layer` call as the OUTERMOST service — the inverse of the tonic
711 /// [`tonic::transport::Server`] builder, where the FIRST `.layer` is
712 /// outermost — so the calls are ordered inner→outer here to land the exact
713 /// same outermost→innermost stack `serve` builds. `accept_http1` has no axum
714 /// analogue: HTTP/1 is implicit in [`axum::serve()`].
715 ///
716 /// ERGONOMIC GUARANTEE: the returned value is a plain `axum::Router` (state
717 /// `()`, request body [`axum::body::Body`]) that [`axum::serve()`] accepts with
718 /// no further ceremony. The layer stack rewrites the response body type; that
719 /// normalization is resolved INTERNALLY (the layered routes are re-nested
720 /// under a fresh [`axum::Router`]), so the consumer needs no
721 /// `Router::<()>::new().merge(...)` re-nest of its own.
722 ///
723 /// Prefer this over [`Self::into_axum_router`] unless you are nesting under a
724 /// listener that ALREADY frames gRPC-web — that expert path is layer-free
725 /// precisely so it does not double-frame in that case.
726 ///
727 /// The downstream must hold [`ChainParts`] (specifically its training-worker
728 /// guard) alive for the lifetime of its own serve loop.
729 pub fn into_layered_axum_router(self) -> (axum::Router, ChainParts) {
730 // The `MetricsLayer` holds a clone; the original moves into `ChainParts`
731 // so the downstream can still scrape the same registry from its own
732 // `/metrics` route.
733 let metrics = Arc::clone(&self.metrics);
734 // Apply the canonical stack in axum's inner→outer call order. axum runs
735 // the last `.layer` as the outermost service, so ordering the calls
736 // GrpcWebLayer → GrpcWebTrailersLayer → MetricsLayer reproduces `serve`'s
737 // outermost→innermost stack: Metrics → GrpcWebTrailers → GrpcWebLayer →
738 // routes.
739 let layered = self
740 .routes
741 .into_axum_router()
742 .layer(GrpcWebLayer::new())
743 .layer(GrpcWebTrailersLayer::new())
744 .layer(MetricsLayer::new(metrics));
745 // Re-nest the layered routes under a fresh `Router` so the returned type
746 // is a plain `axum::Router` whose request body is `axum::body::Body` —
747 // the layer stack's response-body rewrite is absorbed here, and
748 // `axum::serve` accepts the result directly with no consumer-side merge.
749 let router = axum::Router::new().merge(layered);
750 let parts = ChainParts {
751 addr: self.addr,
752 mounted: self.mounted,
753 metrics: self.metrics,
754 #[cfg(feature = "train")]
755 train_worker: self._train_worker,
756 };
757 (router, parts)
758 }
759}
760
761/// An [`AssembledChain`] whose gRPC + Flight SQL listener is bound and held
762/// live, ready to serve. Returned by [`AssembledChain::bind`]. The listener is
763/// open from bind through serve, so [`Self::addr`] reports the ACTUAL bound
764/// address (the real port even for a `:0` assembly) and the port is never
765/// observably free between resolving it and serving on it.
766pub struct BoundChain {
767 // The bound listener, wrapped as tonic's incoming stream with the same
768 // `nodelay` the addr-based serve path applies. Held from bind through serve
769 // — this is the whole point: zero release-then-rebind window.
770 incoming: TcpIncoming,
771 addr: SocketAddr,
772 // The layer-free routes, carried forward so the transport layer stack is
773 // still applied at serve time (G1) rather than baked in at bind.
774 routes: tonic::service::Routes,
775 mounted: Vec<String>,
776 metrics: Arc<MetricsRegistry>,
777 // The embedded training worker guard, held RAII across the serve loop — its
778 // lifetime spans bind → serve, exactly as it did on `AssembledChain`.
779 #[cfg(feature = "train")]
780 _train_worker: Option<jammi_ai::fine_tune::worker::EmbeddedWorker>,
781}
782
783impl BoundChain {
784 /// The ACTUAL address the listener is bound to. For a chain assembled at
785 /// `:0`, this is the ephemeral port the kernel assigned — resolved at
786 /// [`AssembledChain::bind`] and held live until serve.
787 pub fn addr(&self) -> SocketAddr {
788 self.addr
789 }
790
791 /// The ledger of mounted service names, in mount order. Read for a startup
792 /// log; carried through the bind unchanged.
793 pub fn mounted(&self) -> &[String] {
794 &self.mounted
795 }
796
797 /// Serve the bound chain on its already-open listener until `shutdown`
798 /// resolves. Consumes `self`, keeping the training-worker guard alive for
799 /// the whole serve loop.
800 ///
801 /// The transport layers apply HERE, in this order: `accept_http1(true)` then
802 /// `MetricsLayer` (outermost — observes every request by method path before
803 /// routing) then `GrpcWebTrailersLayer` (wraps `GrpcWebLayer`, repairing the
804 /// trailers-only error response into the in-body trailer frame a gRPC-web
805 /// client requires) then `GrpcWebLayer`. Every service mounted via
806 /// [`AssembledChain::mount`], engine or downstream, inherits gRPC-web framing
807 /// + trailer repair with no per-service opt-in.
808 pub async fn serve_with_shutdown(
809 self,
810 shutdown: impl Future<Output = ()> + Send + 'static,
811 ) -> Result<(), ServerError> {
812 tracing::info!(
813 "gRPC chain ({}) listening on {}",
814 self.mounted.join(" + "),
815 self.addr
816 );
817 // The layer stack is deferred to here (G1): holding the post-layer
818 // `Router<L>` would leak the concrete `Stack<…>` layer types into
819 // `BoundChain`. `Routes` is the layer-free accumulation point;
820 // `add_routes` attaches it behind the stack at serve time, then serves
821 // on the pre-bound listener via `serve_with_incoming_shutdown`.
822 let mut server = Server::builder()
823 .accept_http1(true)
824 .layer(MetricsLayer::new(self.metrics))
825 .layer(GrpcWebTrailersLayer::new())
826 .layer(GrpcWebLayer::new());
827 server
828 .add_routes(self.routes)
829 .serve_with_incoming_shutdown(self.incoming, shutdown)
830 .await
831 .map_err(ServerError::from)
832 // `self._train_worker` (train build) is dropped here, after the serve
833 // future resolves — its RAII lifetime spans the whole serve loop.
834 }
835}
836
837/// Assemble the engine's gRPC chain from `chain` **without serving it**, so a
838/// downstream can [`AssembledChain::mount`] additional services onto the
839/// engine's fully-assembled core chain before serving. This is the composability
840/// seam.
841///
842/// **Always mounted** (the core tier + the Flight SQL transport): Flight SQL and
843/// the control-plane `CatalogService` (its engine-free tenant trio +
844/// `GetServerInfo` answer even when no engine is mounted; its catalog /
845/// lifecycle verbs are backed by `engine` when present). When `engine` is
846/// `Some`, the core data-plane services also mount: `EmbeddingService`,
847/// `InferenceService`, `PipelineService`, `AuditService`. These are the
848/// serve-path primitives every deployment needs.
849///
850/// **Mounted by tier** (only when `tiers` selected them):
851/// - `EvalService` ← [`ServiceTier::Eval`]
852/// - `TrainingService` ← [`ServiceTier::Train`] (and only when the `train`
853/// feature is compiled in — the mount code itself is `#[cfg]`-gated)
854/// - `TriggerService` ← [`ServiceTier::Event`], driven by `trigger` being
855/// `Some` (the caller derives the handles iff the event tier is mounted)
856///
857/// `engine` and `trigger` are `Option` so the gRPC-Web / control-plane-only
858/// fixtures (which construct no `InferenceSession`) can mount just the
859/// transport + core handshake. The `tiers` argument is what the
860/// `CatalogService.GetServerInfo` handshake advertises, so it must agree with
861/// what is actually mounted — the caller is responsible for that agreement
862/// (production goes through [`OssServer`], which derives both from one config).
863///
864/// The engine mounts NO `LifecycleService` — the `jammi.v1.lifecycle` contract
865/// is answered by a platform server, not the OSS engine; an OSS server answers
866/// `UNIMPLEMENTED` for those verbs.
867pub fn assemble_grpc_chain(chain: GrpcChain) -> Result<AssembledChain, ServerError> {
868 let GrpcChain {
869 addr,
870 flight_ctx,
871 flight_binding,
872 store,
873 trigger,
874 engine,
875 tiers,
876 metrics,
877 tenant_resolver,
878 } = chain;
879
880 // Flight SQL — MUST-FIX 2: cover the `db.sql` lane through the SAME resolver
881 // as the gRPC plane. The provider resolves each query's scope and binds it,
882 // closing the cross-transport bypass where an authenticated gRPC plane would
883 // still let Flight bind from an unauthenticated header (#220).
884 let provider = TenantBoundProvider::new(
885 flight_ctx.state(),
886 flight_binding,
887 Arc::clone(&tenant_resolver),
888 );
889 let flight = FlightSqlService::new_with_provider(Box::new(provider));
890 let flight_svc = FlightServiceServer::new(flight);
891
892 // The single binder. One `TenantResolverLayer` (holding the one resolver)
893 // wraps every engine service uniformly — no branch, no separate interceptor,
894 // so nothing can double-bind or clobber the resolved tenant. MUST-FIX 1 — the
895 // wrapping applies only to the services THIS function builds; downstream
896 // services mounted via `AssembledChain::mount` (a platform's own pre-auth
897 // `Login`/`Bootstrap`, etc.) stay un-gated by the engine resolver.
898 let resolver_layer = TenantResolverLayer::new(tenant_resolver);
899
900 // Bind one engine service onto `routes` under the resolver layer. `$server`
901 // is the bare `*ServiceServer::new(inner)`; the layer forwards its
902 // `NamedService::NAME` so tonic routing keeps it.
903 macro_rules! mount_engine {
904 ($routes:expr, $mounted:expr, $name:literal, $server:expr) => {{
905 $routes = $routes.add_service(resolver_layer.layer($server));
906 $mounted.push($name.to_string());
907 }};
908 }
909
910 // Accumulate the services layer-free on a `tonic::service::Routes`. The
911 // transport layer stack is deferred to `AssembledChain::serve` (G1): holding
912 // the post-`add_service` `Router<L>` would leak the concrete layer-stack type
913 // into the seam and cannot grow in place (its `add_service` is by-value with
914 // no `Default`). `Routes` is the composition point tonic provides for exactly
915 // this — every service mounted onto it, engine or downstream, then inherits
916 // the gRPC-web framing + trailer repair the serve path applies. Flight SQL is
917 // NOT wrapped by the layer: its `db.sql` lane binds inside the
918 // `TenantBoundProvider` (threaded with the same resolver above).
919 let mut routes = tonic::service::Routes::new(flight_svc);
920 let mut mounted = vec!["Flight SQL".to_string()];
921
922 // The control plane: one `CatalogService` on the always-present core tier.
923 // Its engine-free verbs (the tenant trio + `GetServerInfo`) ride the
924 // `SessionStore` + `TierSet`, so it mounts even on an engine-light
925 // deployment; its catalog / lifecycle verbs delegate to the shared engine
926 // when one is present (`engine.clone()` here, with the original moved into
927 // the engine-services block below).
928 mount_engine!(
929 routes,
930 mounted,
931 "CatalogService",
932 CatalogServiceServer::new(CatalogServer::new(store, tiers.clone(), engine.clone()))
933 );
934
935 // Event tier: TriggerService. Driven by the caller having supplied handles
936 // (it does so iff the event tier is mounted).
937 if let Some(handles) = trigger {
938 mount_engine!(
939 routes,
940 mounted,
941 "TriggerService",
942 TriggerServiceServer::new(TriggerServer::new(
943 handles.topic_repo,
944 handles.publisher,
945 handles.subscriber,
946 ))
947 );
948 }
949
950 // The embedded training worker the `train` tier owns. Moved into the returned
951 // `AssembledChain` so it outlives the assemble frame and spans the serve loop
952 // (RAII). A serve-only build never sets it.
953 #[cfg(feature = "train")]
954 let mut train_worker: Option<jammi_ai::fine_tune::worker::EmbeddedWorker> = None;
955
956 if let Some(session) = engine {
957 // Core tier engine services: always mounted when an engine is present.
958 mount_engine!(
959 routes,
960 mounted,
961 "EmbeddingService",
962 EmbeddingServiceServer::new(EmbeddingServer::new(Arc::clone(&session)))
963 );
964 mount_engine!(
965 routes,
966 mounted,
967 "InferenceService",
968 InferenceServiceServer::new(InferenceServer::new(Arc::clone(&session)))
969 );
970 mount_engine!(
971 routes,
972 mounted,
973 "PipelineService",
974 PipelineServiceServer::new(PipelineServer::new(Arc::clone(&session)))
975 );
976 mount_engine!(
977 routes,
978 mounted,
979 "AuditService",
980 AuditServiceServer::new(AuditServer::new(Arc::clone(&session)))
981 );
982
983 // Eval tier: EvalService.
984 if tiers.contains(ServiceTier::Eval) {
985 mount_engine!(
986 routes,
987 mounted,
988 "EvalService",
989 EvalServiceServer::new(EvalServer::new(Arc::clone(&session)))
990 );
991 }
992
993 // Train tier: TrainingService (all three training kinds — fine-tune,
994 // graph fine-tune, context-predictor). The mount code is `#[cfg]`-gated on
995 // the `train` feature, so a serve-only build carries no training surface;
996 // `TierSet::resolve` has already guaranteed the tier is not requested when
997 // the feature is compiled out.
998 #[cfg(feature = "train")]
999 if tiers.contains(ServiceTier::Train) {
1000 // Start the worker that runs submitted jobs: a "GPU worker pool" is
1001 // just N processes claiming from the shared catalog, and the server
1002 // `train` tier runs one of them. `spawn` borrows `session` before it
1003 // is moved into `TrainingServer::new`; the worker is stored in
1004 // `AssembledChain` so it stops when the serve future resolves.
1005 train_worker = Some(jammi_ai::fine_tune::worker::EmbeddedWorker::spawn(
1006 &session,
1007 )?);
1008 mount_engine!(
1009 routes,
1010 mounted,
1011 "TrainingService",
1012 TrainingServiceServer::new(TrainingServer::new(session))
1013 );
1014 }
1015 }
1016
1017 Ok(AssembledChain {
1018 addr,
1019 routes,
1020 mounted,
1021 metrics,
1022 // The SAME layer `mount_engine!` wrapped every engine service with above
1023 // — retained so `AssembledChain::mount_tenant_scoped` can wrap a
1024 // downstream service through the identical single resolver.
1025 tenant_resolver_layer: resolver_layer,
1026 #[cfg(feature = "train")]
1027 _train_worker: train_worker,
1028 })
1029}
1030
1031/// Build and serve the engine's gRPC chain on `chain.addr` in one call — the
1032/// OSS-only convenience for a caller serving straight from a configured address.
1033///
1034/// A thin composition of the seam: `assemble_grpc_chain(chain)?.serve(...)`,
1035/// which binds the listener eagerly ([`AssembledChain::bind`]) and serves on it.
1036/// A caller that needs the RESOLVED port before serving (an ephemeral `:0` bind)
1037/// drives [`assemble_grpc_chain`] → [`AssembledChain::bind`] →
1038/// [`BoundChain::serve_with_shutdown`] directly and reads the port off the bound
1039/// handle. Downstreams that mount their own services go through
1040/// [`assemble_grpc_chain`] + [`AssembledChain::mount`] instead.
1041pub async fn serve_grpc_chain(
1042 chain: GrpcChain,
1043 shutdown: impl Future<Output = ()> + Send + 'static,
1044) -> Result<(), ServerError> {
1045 assemble_grpc_chain(chain)?.serve(shutdown).await
1046}
1047
1048/// Install OS shutdown handlers and resolve when SIGINT or SIGTERM
1049/// arrives. Mirrors the existing `lib.rs` behaviour so the binary
1050/// shuts down on Ctrl+C and on `docker stop` (which sends SIGTERM).
1051async fn shutdown_signal() {
1052 let ctrl_c = async {
1053 match signal::ctrl_c().await {
1054 Ok(()) => {}
1055 Err(e) => tracing::error!("Failed to install Ctrl+C handler: {e}"),
1056 }
1057 };
1058
1059 #[cfg(unix)]
1060 let terminate = async {
1061 match signal::unix::signal(signal::unix::SignalKind::terminate()) {
1062 Ok(mut sig) => {
1063 sig.recv().await;
1064 }
1065 Err(e) => {
1066 tracing::error!("Failed to install SIGTERM handler: {e}");
1067 std::future::pending::<()>().await;
1068 }
1069 }
1070 };
1071
1072 #[cfg(not(unix))]
1073 let terminate = std::future::pending::<()>();
1074
1075 tokio::select! {
1076 () = ctrl_c => {},
1077 () = terminate => {},
1078 }
1079
1080 tracing::info!("Shutdown signal received, draining connections...");
1081}