Skip to main content

loonfs_server/http/
serve.rs

1//! HTTP application construction, the listener service, and where its
2//! graceful shutdown is triggered from.
3
4use super::metrics::ServerMetrics;
5use super::router;
6use super::tls::{self, TlsConfigError, TlsListener};
7use crate::config::{ServerConfig, ServerConfigError};
8use axum::Router;
9use loonfs::metrics::{JsonlObjectStoreMetricsRecorder, ObjectStoreMetricsRecorder};
10use loonfs::{
11    FsAdmin, FsReader, FsWriter, MaintenanceHandle, MaintenanceJob, MaintenanceProbe,
12    SharedObjectStore, TraceMode, TraceStoreKind,
13};
14use loonfs_api::NamespaceId;
15use loonfs_grep::{GrepGcJob, GrepMaintenanceJob, GrepService, GrepWorker, GREP_INDEX_JOB};
16use loonfs_objectstore::presign::ObjectTransferIssuer;
17use std::ffi::OsString;
18use std::net::SocketAddr;
19use std::sync::Arc;
20use thiserror::Error;
21use tokio::sync::Semaphore;
22
23const OBJECT_STORE_METRICS_JSONL_ENV: &str = "LOONFS_OBJECT_STORE_METRICS_JSONL";
24
25/// Purpose-specific handles over one shared store client: read endpoints go
26/// through `reader`, mutations through `writer` (and the publication service
27/// it hands out), maintenance endpoints through `admin`. `writer` is also
28/// what a host settles at shutdown, and what [`app`] returns beside the
29/// router for that purpose.
30///
31/// `reader` is a cheap clone derived from `writer` at construction, kept as
32/// its own field because most handlers only read.
33#[derive(Clone)]
34pub(super) struct AppState {
35    pub(super) config: Arc<ServerConfig>,
36    pub(super) writer: FsWriter,
37    pub(super) reader: FsReader,
38    pub(super) admin: FsAdmin,
39    /// The store itself, for the one endpoint whose subject is the store
40    /// rather than a namespace: the contract probe. It is the same
41    /// instrumented client the handles were built on, so a probe measures
42    /// what production traffic measures.
43    pub(super) probe_store: SharedObjectStore,
44    pub(super) transfer_issuer: Option<Arc<dyn ObjectTransferIssuer>>,
45    pub(super) grep_worker: Option<GrepWorker<SharedObjectStore>>,
46    /// The grep query service: one process-wide decoded-block cache for
47    /// grep's own segments, held here because grep is a composed extension
48    /// rather than part of the runtime the handles come from.
49    pub(super) grep_service: Option<Arc<GrepService>>,
50    /// Present when this deployment maintains the index automatically: how a
51    /// request tells the writer's runner a namespace may have indexing to
52    /// do. Absent under `maintenance = "manual"`, where the mutating index
53    /// routes still work and nothing schedules itself behind them.
54    pub(super) grep_maintenance: Option<GrepMaintenance>,
55    /// Bounds concurrently buffered proxied-upload bodies; with the
56    /// per-request body limit this makes worst-case upload memory
57    /// `max_concurrent_uploads * max_upload_bytes`. Requests past the cap
58    /// answer 503 `server_busy` before any buffering.
59    pub(super) upload_permits: Arc<Semaphore>,
60    /// Bounds concurrently materialized proxied content reads the same way:
61    /// worst-case download memory is
62    /// `max_concurrent_downloads * max_download_bytes`.
63    pub(super) download_permits: Arc<Semaphore>,
64    /// The recorder every handle in this process reports through, and the
65    /// request-level instruments only this server can report. `GET /metrics`
66    /// renders its snapshot. Always installed: a metrics surface a
67    /// deployment has to remember to switch on is a metrics surface nobody
68    /// has during the incident.
69    pub(super) metrics: Arc<ServerMetrics>,
70}
71
72/// Everything a request path tells the writer's maintenance runner about
73/// the grep index, and the one question it asks before telling it anything.
74///
75/// The runner owns admission, the permit pool, backoff, and shutdown: this
76/// is a nudge and a probe, both cheap and neither blocking.
77#[derive(Clone)]
78pub(super) struct GrepMaintenance {
79    handle: MaintenanceHandle,
80    job: Arc<GrepMaintenanceJob<SharedObjectStore>>,
81}
82
83impl GrepMaintenance {
84    /// Asks for one bounded indexing step as soon as a permit frees.
85    /// Repeated asks coalesce into one run.
86    pub(super) fn nudge(&self, namespace_id: &NamespaceId) {
87        self.handle.nudge(GREP_INDEX_JOB, namespace_id);
88    }
89
90    /// Nudges only a namespace whose index is actually behind.
91    ///
92    /// A read has no business admitting work that does not exist, and the
93    /// job already knows how to answer that question in at most two small
94    /// reads. An unreadable answer nudges nothing: the step would only
95    /// rediscover the same failure.
96    pub(super) async fn nudge_if_behind(&self, namespace_id: &NamespaceId) {
97        if matches!(
98            self.job.probe(namespace_id).await,
99            Ok(MaintenanceProbe::Due)
100        ) {
101            self.nudge(namespace_id);
102        }
103    }
104}
105
106/// Builds the HTTP application: the router that serves requests, and the
107/// writer whose background work its host must settle.
108///
109/// Everything this app spawns belongs to that writer — publications, and
110/// the maintenance runner that admits the runtime's steps alongside the
111/// grep index's. [`serve`] settles it itself. A host embedding the
112/// [`Router`] on its own HTTP server must call [`FsWriter::shutdown`] after
113/// its listener drains, or publisher tasks and writer maintenance outlive
114/// the listener unobserved. The writer also answers what a deployment's
115/// shape is, so a host that needs to know whether the grep index job is
116/// registered here asks
117/// [`FsWriter::maintenance_job`](loonfs::FsWriter::maintenance_job).
118pub async fn app(config: ServerConfig) -> Result<(Router, FsWriter), ServerConfigError> {
119    // The one unavoidable validation point: configs that skipped
120    // `load_server_config` (direct Rust construction) fail here exactly as
121    // file-loaded ones fail at load.
122    config.validate()?;
123    let store = config.object_store()?;
124    // The one direct-put gate. A presigned URL is a capability handed to a
125    // client, and completion trusts the provider to have enforced the signed
126    // checksum and create-only preconditions rather than reading the bytes
127    // back — so an issuer exists only when the store can presign *and* the
128    // endpoint is one the live conformance suite has proven.
129    let transfer_issuer = config
130        .store
131        .direct_put_is_proven()
132        .then(|| store.transfer_issuer())
133        .flatten();
134    let store = store.into_shared();
135    let (router, state) =
136        app_with_store_and_transfer_issuer(config, store, transfer_issuer).await?;
137    Ok((router, state.writer))
138}
139
140#[cfg(test)]
141pub(super) async fn app_with_store(
142    config: ServerConfig,
143    store: SharedObjectStore,
144) -> Result<Router, ServerConfigError> {
145    Ok(app_with_store_and_transfer_issuer(config, store, None)
146        .await?
147        .0)
148}
149
150/// Test-only: the router plus its state, so tests can hold admission
151/// permits or close publisher admission and observe the served answers.
152#[cfg(test)]
153pub(super) async fn app_with_store_and_state(
154    config: ServerConfig,
155    store: SharedObjectStore,
156) -> Result<(Router, AppState), ServerConfigError> {
157    app_with_store_and_transfer_issuer(config, store, None).await
158}
159
160pub(super) async fn app_with_store_and_transfer_issuer(
161    config: ServerConfig,
162    store: SharedObjectStore,
163    transfer_issuer: Option<Arc<dyn ObjectTransferIssuer>>,
164) -> Result<(Router, AppState), ServerConfigError> {
165    let metrics = ServerMetrics::new();
166    // Two switches decide automatic grep indexing and nothing else does:
167    // whether this server maintains anything automatically, and whether its
168    // grep mode maintains the index.
169    let maintains_grep_index =
170        config.maintenance.registers_automatic_jobs() && config.grep.mode.maintains_index();
171    // Grep reads and checkpoints through the same handles the HTTP planes
172    // use, so it is composed after them. Nothing has to be wired back into
173    // the writer for its publications to reach the index: the job says on
174    // the trait that publications concern it, and registering it is what
175    // subscribes it.
176    let (writer, reader, admin) = build_handles(
177        &config,
178        store,
179        &metrics,
180        std::env::var_os(OBJECT_STORE_METRICS_JSONL_ENV),
181    )
182    .await?;
183    let probe_store = writer.object_store();
184    // A deployment that maintains the index needs a worker whether or not it
185    // answers queries with one. It runs on the writer's own instrumented
186    // client, so the grep-owned traffic is measured like every other
187    // request instead of escaping on a second, raw client.
188    let grep_worker = (config.grep.mode.serves_grep() || config.grep.mode.maintains_index())
189        .then(|| GrepWorker::new(writer.object_store(), reader.clone(), admin.clone()));
190    let grep_service = config
191        .grep
192        .mode
193        .serves_grep()
194        .then(|| Arc::new(GrepService::new()));
195    let grep_maintenance = if maintains_grep_index {
196        let policy = config
197            .grep
198            .worker_config()
199            .build_policy()
200            .map_err(|error| ServerConfigError::InvalidField {
201                field: "grep",
202                reason: error.to_string(),
203            })?;
204        let job = Arc::new(GrepMaintenanceJob::new(
205            grep_worker
206                .as_ref()
207                .expect("an index-maintaining deployment composes a grep worker")
208                .clone(),
209            policy,
210        ));
211        writer
212            .register_maintenance_job(job.clone())
213            .map_err(|error| ServerConfigError::InvalidField {
214                field: "grep",
215                reason: error.to_string(),
216            })?;
217        // Reclaiming what the index leaves behind is upkeep for the same
218        // namespaces, gated by the same switch: a deployment that builds
219        // grep objects is the one that should collect them.
220        writer
221            .register_maintenance_job(Arc::new(GrepGcJob::new(
222                grep_worker
223                    .as_ref()
224                    .expect("an index-maintaining deployment composes a grep worker")
225                    .clone(),
226            )))
227            .map_err(|error| ServerConfigError::InvalidField {
228                field: "grep",
229                reason: error.to_string(),
230            })?;
231        Some(GrepMaintenance {
232            handle: writer.maintenance(),
233            job,
234        })
235    } else {
236        None
237    };
238    let config = Arc::new(config);
239    let state = AppState {
240        upload_permits: Arc::new(Semaphore::new(
241            config.max_concurrent_uploads.min(Semaphore::MAX_PERMITS),
242        )),
243        download_permits: Arc::new(Semaphore::new(
244            config.max_concurrent_downloads.min(Semaphore::MAX_PERMITS),
245        )),
246        config,
247        writer,
248        reader,
249        admin,
250        probe_store,
251        transfer_issuer,
252        grep_worker,
253        grep_service,
254        grep_maintenance,
255        metrics,
256    };
257    Ok((router(state.clone()), state))
258}
259
260#[cfg(test)]
261pub(super) async fn build_handles_with_metrics_jsonl_path(
262    config: &ServerConfig,
263    store: SharedObjectStore,
264    metrics_jsonl_path: Option<OsString>,
265) -> Result<(FsWriter, FsReader, FsAdmin), ServerConfigError> {
266    build_handles(config, store, &ServerMetrics::new(), metrics_jsonl_path).await
267}
268
269/// Opens the process's handles on one store, with the metrics wiring every
270/// deployment gets.
271///
272/// Both handles report through the same recorder, so their instruments are
273/// one set of numbers rather than two. The optional JSONL path adds a second
274/// sink for the raw object-store samples; the handle fans one store wrapper
275/// out to both rather than stacking two.
276async fn build_handles(
277    config: &ServerConfig,
278    store: SharedObjectStore,
279    metrics: &ServerMetrics,
280    metrics_jsonl_path: Option<OsString>,
281) -> Result<(FsWriter, FsReader, FsAdmin), ServerConfigError> {
282    let trace_store_kind = TraceStoreKind::from(config.store.kind());
283    let samples = object_store_metrics_recorder(metrics_jsonl_path)?;
284    let runtime_error = |error: loonfs::RuntimeError| ServerConfigError::InvalidField {
285        field: "runtime",
286        reason: error.to_string(),
287    };
288
289    let mut writer_builder = FsWriter::builder_with_store(store.clone())
290        .writer_id(config.writer_id.clone())
291        .background_work(config.maintenance.background_work())
292        .min_publish_interval_ms(config.min_publish_interval_ms)
293        // The reader below shares this core, so the read cap covers every
294        // proxied content read the server serves.
295        .max_read_content_bytes(config.max_download_bytes)
296        .max_concurrent_maintenance(config.max_concurrent_maintenance)
297        .runtime_cache(config.runtime_cache_config())
298        .trace_mode(TraceMode::Remote)
299        .trace_store_kind(trace_store_kind)
300        .metrics_recorder(metrics.recorder());
301    if let Some(samples) = &samples {
302        writer_builder = writer_builder.object_store_metrics_recorder(Arc::clone(samples));
303    }
304    let writer = writer_builder.build().await.map_err(runtime_error)?;
305    let reader = writer.reader();
306
307    let mut admin_builder = FsAdmin::builder_with_store(store)
308        .actor_id(format!("{}-admin", config.writer_id))
309        // The admin honors the configured cache sizing and shares the
310        // writer's decoded-block cache instance, so explicit maintenance
311        // reuses blocks reader traffic already decoded instead of
312        // populating a second, default-sized cache.
313        .runtime_cache(config.runtime_cache_config())
314        .shared_metadata_table_cache(&writer)
315        .trace_mode(TraceMode::Remote)
316        .trace_store_kind(trace_store_kind)
317        .metrics_recorder(metrics.recorder());
318    if let Some(samples) = samples {
319        admin_builder = admin_builder.object_store_metrics_recorder(samples);
320    }
321    let admin = admin_builder.build().await.map_err(runtime_error)?;
322
323    Ok((writer, reader, admin))
324}
325
326fn object_store_metrics_recorder(
327    metrics_jsonl_path: Option<OsString>,
328) -> Result<Option<Arc<dyn ObjectStoreMetricsRecorder>>, ServerConfigError> {
329    let Some(path) = metrics_jsonl_path else {
330        return Ok(None);
331    };
332    if path.is_empty() {
333        return Ok(None);
334    }
335    let path = std::path::PathBuf::from(path);
336    JsonlObjectStoreMetricsRecorder::create(&path)
337        .map(|recorder| Some(Arc::new(recorder) as Arc<dyn ObjectStoreMetricsRecorder>))
338        .map_err(|error| ServerConfigError::InvalidField {
339            field: OBJECT_STORE_METRICS_JSONL_ENV,
340            reason: error.to_string(),
341        })
342}
343
344/// Failure starting or running the HTTP server.
345#[derive(Debug, Error)]
346pub enum ServeError {
347    #[error("invalid server config: {0}")]
348    Config(#[from] ServerConfigError),
349    #[error("failed to bind `{addr}`: {source}")]
350    Bind {
351        addr: SocketAddr,
352        #[source]
353        source: std::io::Error,
354    },
355    #[error("failed to load the configured TLS identity: {0}")]
356    Tls(#[source] TlsConfigError),
357    #[error("server failed while serving requests: {0}")]
358    Serve(#[source] std::io::Error),
359    #[error("background work did not settle during shutdown: {0}")]
360    Shutdown(#[source] loonfs::RuntimeError),
361}
362
363/// Serves until ctrl-c or SIGTERM, then shuts down gracefully: the listener
364/// stops accepting, in-flight requests drain, publisher work finishes, and
365/// writer maintenance — the runtime's steps and grep's alike — settles
366/// before this returns.
367pub async fn serve(config: ServerConfig) -> Result<(), ServeError> {
368    serve_with_shutdown(config, shutdown_signal()).await
369}
370
371/// [`serve`] with a caller-supplied shutdown trigger instead of process
372/// signals, for hosts that manage their own lifecycle.
373pub async fn serve_with_shutdown(
374    config: ServerConfig,
375    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
376) -> Result<(), ServeError> {
377    let bind = config.bind_addr()?;
378    // The identity is loaded before the bind, so a deployment with an
379    // unreadable certificate fails without ever having held the port.
380    let tls = config
381        .tls
382        .as_ref()
383        .map(tls::server_config)
384        .transpose()
385        .map_err(ServeError::Tls)?;
386    let listener = tokio::net::TcpListener::bind(bind)
387        .await
388        .map_err(|source| ServeError::Bind { addr: bind, source })?;
389    match tls {
390        Some(tls) => serve_on(TlsListener::new(listener, tls), config, shutdown).await,
391        None => serve_on(listener, config, shutdown).await,
392    }
393}
394
395/// The one serving body, over whichever listener the deployment configured.
396/// Plaintext and TLS differ in what `accept` returns and in nothing else:
397/// the same router, the same graceful shutdown, and the same writer settles
398/// after the listener has drained.
399pub(super) async fn serve_on<L>(
400    listener: L,
401    config: ServerConfig,
402    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
403) -> Result<(), ServeError>
404where
405    L: axum::serve::Listener<Addr = SocketAddr>,
406{
407    let (router, writer) = app(config).await?;
408    axum::serve(listener, router)
409        .with_graceful_shutdown(shutdown)
410        .await
411        .map_err(ServeError::Serve)?;
412    // Only once the listener has drained: the writer's shutdown refuses new
413    // mutations, so running it while requests are still arriving would fail
414    // work this server accepted. What order the shutdown itself runs in is
415    // the writer's business, not this function's. Panicked tasks surface
416    // here rather than disappearing with the process.
417    writer.shutdown().await.map_err(ServeError::Shutdown)
418}
419
420/// Resolves on ctrl-c or, on unix, SIGTERM — the stop signal container
421/// orchestrators send before a kill.
422async fn shutdown_signal() {
423    let ctrl_c = async {
424        tokio::signal::ctrl_c()
425            .await
426            .expect("ctrl-c handler should install");
427    };
428    #[cfg(unix)]
429    let terminate = async {
430        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
431            .expect("SIGTERM handler should install")
432            .recv()
433            .await;
434    };
435    #[cfg(not(unix))]
436    let terminate = std::future::pending::<()>();
437    tokio::select! {
438        () = ctrl_c => {}
439        _ = terminate => {}
440    }
441}