Skip to main content

fraiseql_server/server/
lifecycle.rs

1//! Server lifecycle: serve, `serve_with_shutdown`, and `shutdown_signal`.
2
3use std::net::SocketAddr;
4
5use axum::serve::ListenerExt;
6use tokio::net::TcpListener;
7use tracing::{error, info, warn};
8
9use super::{DatabaseAdapter, Result, Server, ServerError, TlsSetup};
10#[cfg(feature = "observers")]
11use crate::subscriptions::event_bridge::{EventBridge, EventBridgeConfig};
12
13impl<A: DatabaseAdapter + Clone + Send + Sync + 'static> Server<A> {
14    /// Start server and listen for requests.
15    ///
16    /// Uses SIGUSR1-aware shutdown signal when a schema path is configured,
17    /// enabling zero-downtime schema reloads via `kill -USR1 <pid>`.
18    ///
19    /// # Errors
20    ///
21    /// Returns error if server fails to bind or encounters runtime errors.
22    pub async fn serve(self) -> Result<()> {
23        self.serve_with_shutdown(Self::shutdown_signal()).await
24    }
25
26    /// Start server with a custom shutdown future.
27    ///
28    /// Enables programmatic shutdown (e.g., for `--watch` hot-reload) by accepting any
29    /// future that resolves when the server should stop.
30    ///
31    /// # Errors
32    ///
33    /// Returns error if server fails to bind or encounters runtime errors.
34    #[allow(clippy::cognitive_complexity)] // Reason: server lifecycle with TLS/non-TLS binding, signal handling, and graceful shutdown
35    pub async fn serve_with_shutdown<F>(mut self, shutdown: F) -> Result<()>
36    where
37        F: std::future::Future<Output = ()> + Send + 'static,
38    {
39        // Ensure RBAC schema exists before the router mounts RBAC endpoints.
40        // Must run here (async context) rather than inside build_router() (sync).
41        #[cfg(feature = "observers")]
42        if let Some(ref db_pool) = self.db_pool {
43            if self.config.admin_token.is_some() {
44                let rbac_backend =
45                    crate::api::rbac_management::db_backend::RbacDbBackend::new(db_pool.clone());
46                rbac_backend.ensure_schema().await.map_err(|e| {
47                    ServerError::ConfigError(format!("Failed to initialize RBAC schema: {e}"))
48                })?;
49            }
50        }
51
52        // Initialize usage persistence backend if configured.
53        // Must run before build_router() so the aggregator is populated before
54        // serving requests, but after the DB pool is available (async context).
55        if let Some(ref usage_cfg) = self.config.usage.clone() {
56            use std::time::Duration;
57
58            use sqlx::postgres::PgPoolOptions;
59            use tokio::time::MissedTickBehavior;
60
61            use crate::usage::aggregator::{PostgresBackend, global_aggregator};
62
63            match PgPoolOptions::new()
64                .max_connections(2) // small dedicated pool — only used for periodic flushes
65                .connect(&self.config.database_url)
66                .await
67            {
68                Ok(pool) => {
69                    match PostgresBackend::new(pool).await {
70                        Ok(backend) => {
71                            let backend = std::sync::Arc::new(backend);
72                            // Upgrade global aggregator's backend from NoopBackend.
73                            global_aggregator().set_backend(backend.clone());
74                            // Restore persisted counters before serving requests.
75                            if let Err(e) = global_aggregator().load_from_backend().await {
76                                warn!(error = %e, "Usage persistence: startup load failed — continuing with in-memory counters");
77                            } else {
78                                info!("Usage persistence: loaded counters from PostgreSQL");
79                            }
80                            // Spawn background flush task on the server's JoinSet
81                            // so graceful shutdown can await its termination.
82                            let flush_interval = Duration::from_secs(usage_cfg.flush_interval_secs);
83                            let agg = std::sync::Arc::clone(global_aggregator());
84                            self.tasks.spawn(async move {
85                                let mut ticker = tokio::time::interval(flush_interval);
86                                ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
87                                ticker.tick().await; // skip immediate first tick
88                                loop {
89                                    ticker.tick().await;
90                                    if let Err(e) = agg.flush_to_backend().await {
91                                        warn!(error = %e, "Usage persistence: background flush failed");
92                                    }
93                                }
94                            });
95                            info!(
96                                flush_interval_secs = usage_cfg.flush_interval_secs,
97                                "Usage persistence: PostgreSQL backend active"
98                            );
99                        },
100                        Err(e) => {
101                            warn!(
102                                error = %e,
103                                "Usage persistence: PostgresBackend initialization failed — \
104                                 continuing with in-memory (NoopBackend)"
105                            );
106                        },
107                    }
108                },
109                Err(e) => {
110                    warn!(
111                        error = %e,
112                        "Usage persistence: failed to connect to PostgreSQL — \
113                         continuing with in-memory (NoopBackend)"
114                    );
115                },
116            }
117        }
118
119        let (app, app_state) = self.build_router();
120
121        // Spawn SIGUSR1 schema reload handler when running on Unix.
122        // The handler loops forever, reloading on each signal, until the
123        // server process exits — tracked on the server's JoinSet so graceful
124        // shutdown awaits its termination.
125        #[cfg(unix)]
126        if let Some(ref schema_path) = app_state.schema_path {
127            let reload_state = app_state.clone();
128            let reload_path = schema_path.clone();
129            self.tasks.spawn(async move {
130                let mut sigusr1 = match tokio::signal::unix::signal(
131                    tokio::signal::unix::SignalKind::user_defined1(),
132                ) {
133                    Ok(s) => s,
134                    Err(e) => {
135                        warn!(error = %e, "Failed to install SIGUSR1 handler — schema hot-reload disabled");
136                        return;
137                    },
138                };
139                loop {
140                    sigusr1.recv().await;
141                    info!(
142                        path = %reload_path.display(),
143                        "Received SIGUSR1 — reloading schema"
144                    );
145                    match reload_state.reload_schema(&reload_path).await {
146                        Ok(()) => {
147                            let hash = reload_state.executor().schema().content_hash();
148                            reload_state
149                                .metrics
150                                .schema_reloads_total
151                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
152                            info!(schema_hash = %hash, "Schema reloaded successfully via SIGUSR1");
153                        },
154                        Err(e) => {
155                            reload_state
156                                .metrics
157                                .schema_reload_errors_total
158                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
159                            error!(
160                                error = %e,
161                                path = %reload_path.display(),
162                                "Schema reload failed via SIGUSR1 — keeping previous schema"
163                            );
164                        },
165                    }
166                }
167            });
168            info!(
169                path = %schema_path.display(),
170                "SIGUSR1 schema reload handler installed"
171            );
172        }
173
174        // Initialize TLS setup (database connection TLS; server-side TLS is unsupported).
175        let tls_setup = TlsSetup::new(self.config.tls.clone(), self.config.database_tls.clone());
176
177        // Refuse to boot if server-side `[tls]` is enabled. FraiseQL does not terminate TLS
178        // itself — it serves plaintext and expects a reverse proxy / load balancer / service
179        // mesh to terminate TLS in front of it. Previously an enabled `[tls]` built a rustls
180        // config that was silently discarded while the server kept serving plaintext and
181        // logged `mtls_required = true` (M-tls-enforce); failing loud is honest.
182        if tls_setup.is_tls_enabled() {
183            return Err(ServerError::ConfigError(
184                "[tls] (server-side TLS termination) is enabled but not supported: FraiseQL \
185                 serves plaintext HTTP and expects TLS to be terminated by a reverse proxy, \
186                 load balancer, or service mesh. Remove the [tls] section (or set its \
187                 `enabled = false`) and terminate TLS in front of the server. Database \
188                 connection TLS ([database_tls]) is unaffected."
189                    .to_string(),
190            ));
191        }
192
193        info!(
194            bind_addr = %self.config.bind_addr,
195            graphql_path = %self.config.graphql_path,
196            tls_enabled = tls_setup.is_tls_enabled(),
197            "Starting FraiseQL server"
198        );
199
200        // Start observer runtime if configured, wiring CDC events to EventBridge
201        #[cfg(feature = "observers")]
202        #[allow(unused_variables)]
203        // Reason: _bridge_handle is kept alive to prevent task cancellation
204        let _bridge_handle = {
205            let mut handle: Option<tokio::task::JoinHandle<()>> = None;
206            if let Some(ref runtime) = self.observer_runtime {
207                info!("Starting observer runtime...");
208
209                // Create EventBridge to forward CDC events to GraphQL subscriptions
210                let bridge =
211                    EventBridge::new(self.subscription_manager.clone(), EventBridgeConfig::new());
212                let sender = bridge.sender();
213
214                let mut guard = runtime.write().await;
215                guard.set_event_bridge_sender(sender);
216
217                match guard.start().await {
218                    Ok(()) => {
219                        info!("Observer runtime started");
220                        // Spawn EventBridge after observer runtime is running
221                        handle = Some(bridge.spawn());
222                        info!(
223                            "EventBridge started — CDC events will be forwarded to subscriptions"
224                        );
225                    },
226                    Err(e) => {
227                        // A broker-backed transport (NATS) was an explicit operator
228                        // choice; refusing to boot in production rather than silently
229                        // coming up without it is the #350 dead-broker contract. The
230                        // default PostgreSQL transport keeps the resilient
231                        // log-and-continue behaviour (and development downgrades the
232                        // NATS failure to the same warning).
233                        if guard.transport_requires_broker()
234                            && crate::ServerConfig::is_production_mode()
235                        {
236                            error!(
237                                error = %e,
238                                "Observer runtime failed to start on its configured \
239                                 transport; refusing to boot (set FRAISEQL_ENV=development \
240                                 to downgrade to a warning)"
241                            );
242                            return Err(e);
243                        }
244                        error!("Failed to start observer runtime: {}", e);
245                        warn!("Server will continue without observers");
246                    },
247                }
248                drop(guard);
249            }
250            handle
251        };
252
253        // Explicitly enable TCP_NODELAY (disable Nagle's algorithm) on every
254        // accepted connection to minimise latency for small GraphQL responses.
255        let listener = TcpListener::bind(self.config.bind_addr)
256            .await
257            .map_err(|e| ServerError::BindError(e.to_string()))?
258            .tap_io(|tcp_stream| {
259                if let Err(err) = tcp_stream.set_nodelay(true) {
260                    warn!("failed to set TCP_NODELAY: {err:#}");
261                }
262            });
263
264        // Warn if the process file descriptor limit is below the recommended minimum.
265        // A low limit causes "too many open files" errors under load.
266        #[cfg(target_os = "linux")]
267        {
268            if let Ok(limits) = std::fs::read_to_string("/proc/self/limits") {
269                for line in limits.lines() {
270                    if line.starts_with("Max open files") {
271                        let parts: Vec<&str> = line.split_whitespace().collect();
272                        if let Some(soft) = parts.get(3) {
273                            if let Ok(n) = soft.parse::<u64>() {
274                                if n < 65_536 {
275                                    warn!(
276                                        current_fd_limit = n,
277                                        recommended = 65_536,
278                                        "File descriptor limit is low; consider raising ulimit -n"
279                                    );
280                                }
281                            }
282                        }
283                        break;
284                    }
285                }
286            }
287        }
288
289        // Log database TLS configuration
290        info!(
291            postgres_ssl_mode = tls_setup.postgres_ssl_mode(),
292            redis_ssl = tls_setup.redis_ssl_enabled(),
293            clickhouse_https = tls_setup.clickhouse_https_enabled(),
294            elasticsearch_https = tls_setup.elasticsearch_https_enabled(),
295            "Database connection TLS configuration applied"
296        );
297
298        info!("Server listening on http://{}", self.config.bind_addr);
299
300        // Start both HTTP and gRPC servers concurrently if Arrow Flight is enabled
301        #[cfg(feature = "arrow")]
302        if let Some(flight_service) = self.flight_service.take() {
303            let flight_addr = self.config.flight_bind_addr;
304            info!("Arrow Flight server listening on grpc://{}", flight_addr);
305
306            // Spawn Flight server in background, registered on the server's
307            // JoinSet. The set's `shutdown` step abort-then-awaits the gRPC
308            // server when the HTTP server exits.
309            self.tasks.spawn(async move {
310                if let Err(e) = tonic::transport::Server::builder()
311                    .add_service(flight_service.into_server())
312                    .serve(flight_addr)
313                    .await
314                {
315                    error!(error = %e, "Arrow Flight server terminated with error");
316                }
317            });
318
319            // Wrap the user-supplied shutdown future so we can also stop observer runtime
320            #[cfg(feature = "observers")]
321            let observer_runtime = self.observer_runtime.clone();
322
323            let shutdown_with_cleanup = async move {
324                shutdown.await;
325                #[cfg(feature = "observers")]
326                if let Some(ref runtime) = observer_runtime {
327                    info!("Shutting down observer runtime");
328                    let mut guard = runtime.write().await;
329                    if let Err(e) = guard.stop().await {
330                        #[cfg(feature = "observers")]
331                        error!("Error stopping runtime: {}", e);
332                    } else {
333                        info!("Runtime stopped cleanly");
334                    }
335                }
336            };
337
338            // Run HTTP server with graceful shutdown
339            axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
340                .with_graceful_shutdown(shutdown_with_cleanup)
341                .await
342                .map_err(|e| ServerError::IoError(std::io::Error::other(e)))?;
343
344            // Abort and await every lifecycle task (Flight server, SIGUSR1
345            // handler, PKCE cleanup, trusted-docs reload, usage flush, …).
346            drain_lifecycle_tasks(self.tasks, self.config.shutdown_timeout_secs).await;
347        }
348
349        // HTTP-only server (when arrow feature not enabled)
350        #[cfg(not(feature = "arrow"))]
351        {
352            axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
353                .with_graceful_shutdown(shutdown)
354                .await
355                .map_err(|e| ServerError::IoError(std::io::Error::other(e)))?;
356
357            let shutdown_timeout =
358                std::time::Duration::from_secs(self.config.shutdown_timeout_secs);
359            info!(
360                timeout_secs = self.config.shutdown_timeout_secs,
361                "HTTP server stopped, draining remaining work"
362            );
363
364            let drain = tokio::time::timeout(shutdown_timeout, async {
365                #[cfg(feature = "observers")]
366                if let Some(ref runtime) = self.observer_runtime {
367                    let mut guard = runtime.write().await;
368                    match guard.stop().await {
369                        Ok(()) => info!("Observer runtime stopped cleanly"),
370                        Err(e) => warn!("Observer runtime shutdown error: {e}"),
371                    }
372                }
373            })
374            .await;
375
376            if drain.is_err() {
377                warn!(
378                    timeout_secs = self.config.shutdown_timeout_secs,
379                    "Shutdown drain timed out; forcing exit"
380                );
381            } else {
382                info!("Graceful shutdown complete");
383            }
384
385            // Abort and await every lifecycle task (SIGUSR1 handler, PKCE
386            // cleanup, trusted-docs reload, usage flush, …).
387            drain_lifecycle_tasks(self.tasks, self.config.shutdown_timeout_secs).await;
388        }
389
390        Ok(())
391    }
392
393    /// Start server on an externally created listener.
394    ///
395    /// Used in tests to discover the bound port before serving.
396    /// Skips TLS, Flight, and observer startup — suitable for unit/integration tests only.
397    ///
398    /// # Errors
399    ///
400    /// Returns error if the server encounters a runtime error.
401    pub async fn serve_on_listener<F>(self, listener: TcpListener, shutdown: F) -> Result<()>
402    where
403        F: std::future::Future<Output = ()> + Send + 'static,
404    {
405        let (app, _app_state) = self.build_router();
406        axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
407            .with_graceful_shutdown(shutdown)
408            .await
409            .map_err(|e| ServerError::IoError(std::io::Error::other(e)))?;
410        // Abort and await any lifecycle tasks spawned during construction
411        // (e.g. PKCE cleanup, trusted-docs reload) so the test path doesn't
412        // leak background work into the next test.
413        drain_lifecycle_tasks(self.tasks, self.config.shutdown_timeout_secs).await;
414        Ok(())
415    }
416
417    /// Listen for shutdown signals (Ctrl+C or SIGTERM)
418    pub async fn shutdown_signal() {
419        use tokio::signal;
420
421        let ctrl_c = async {
422            match signal::ctrl_c().await {
423                Ok(()) => {},
424                Err(e) => {
425                    warn!(error = %e, "Failed to install Ctrl+C handler");
426                    std::future::pending::<()>().await;
427                },
428            }
429        };
430
431        #[cfg(unix)]
432        let terminate = async {
433            match signal::unix::signal(signal::unix::SignalKind::terminate()) {
434                Ok(mut s) => {
435                    s.recv().await;
436                },
437                Err(e) => {
438                    warn!(error = %e, "Failed to install SIGTERM handler");
439                    std::future::pending::<()>().await;
440                },
441            }
442        };
443
444        #[cfg(not(unix))]
445        let terminate = std::future::pending::<()>();
446
447        tokio::select! {
448            () = ctrl_c => info!("Received Ctrl+C"),
449            () = terminate => info!("Received SIGTERM"),
450        }
451    }
452}
453
454/// Abort every lifecycle task on the supplied [`tokio::task::JoinSet`] and await
455/// the resulting `JoinError`s so the runtime is fully drained before
456/// `serve_with_shutdown` returns.
457///
458/// Tasks are awaited under an outer timeout so a stuck task cannot prevent
459/// process exit. A `JoinError::is_cancelled()` after `JoinSet::abort_all` is
460/// the expected case — only unexpected panics are logged.
461pub(super) async fn drain_lifecycle_tasks(
462    mut tasks: tokio::task::JoinSet<()>,
463    shutdown_timeout_secs: u64,
464) {
465    if tasks.is_empty() {
466        return;
467    }
468
469    tasks.abort_all();
470    let timeout = std::time::Duration::from_secs(shutdown_timeout_secs);
471    let drained = tokio::time::timeout(timeout, async {
472        while let Some(res) = tasks.join_next().await {
473            if let Err(e) = res {
474                if !e.is_cancelled() {
475                    warn!(error = %e, "Lifecycle task terminated with a non-cancellation error");
476                }
477            }
478        }
479    })
480    .await;
481    if drained.is_err() {
482        warn!(
483            timeout_secs = shutdown_timeout_secs,
484            "Lifecycle task drain timed out; some background tasks did not stop in time"
485        );
486    } else {
487        info!("All lifecycle background tasks drained");
488    }
489}