Skip to main content

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}