ifc_lite_server/
main.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! IFC-Lite Server - High-performance IFC processing server.
6//!
7//! This server provides a REST API for parsing IFC files and extracting
8//! geometry. It supports:
9//!
10//! - Full synchronous parsing with caching
11//! - Streaming Server-Sent Events for progressive rendering
12//! - Quick metadata extraction without geometry processing
13//!
14//! # Endpoints
15//!
16//! - `GET /api/v1/health` - Health check
17//! - `POST /api/v1/parse` - Full parse with all geometry (JSON)
18//! - `POST /api/v1/parse/stream` - Streaming parse (SSE)
19//! - `POST /api/v1/parse/metadata` - Quick metadata only
20//! - `POST /api/v1/parse/parquet` - Full parse with Parquet-encoded geometry (~15x smaller)
21//! - `POST /api/v1/parse/parquet/optimized` - ara3d BOS-optimized format (~50x smaller)
22//! - `GET /api/v1/parse/symbolic/:cache_key` - 2D symbol stream (IfcAnnotation + IfcGrid) as JSON
23//! - `GET /api/v1/cache/:key` - Retrieve cached result
24//! - `DELETE /api/v1/cache/:key` - Invalidate every cache entry for the source file a `cache_key` names
25
26// Native global allocator (#1623): the platform system heap's global lock was
27// ~70% of native geometry self-time and capped rayon scaling to ~1.8x on
28// IfcMappedItem-heavy models; mimalloc's per-thread heaps lifted an all-cores
29// geometry pass on a 462MB metering-station model 35s -> 16.7s (2.1x). See the
30// `mimalloc` feature.
31#[cfg(feature = "mimalloc")]
32#[global_allocator]
33static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
34
35use anyhow::Context;
36use axum::http::{header, HeaderValue, Method};
37use axum::{
38    extract::DefaultBodyLimit,
39    routing::{get, post},
40    Router,
41};
42use std::net::SocketAddr;
43use std::sync::Arc;
44use std::time::Duration;
45use tower_http::{
46    catch_panic::CatchPanicLayer, compression::CompressionLayer, cors::CorsLayer,
47    timeout::TimeoutLayer, trace::TraceLayer,
48};
49
50mod admission;
51mod config;
52mod mem_policy;
53mod error;
54mod in_flight;
55mod middleware;
56mod panic_strategy;
57mod routes;
58mod services;
59mod write_timeout;
60mod types;
61
62/// Slack added on top of `max_file_size_mb` for the raw framework-level body
63/// limit — covers multipart boundary/header overhead so the app's own
64/// `FileTooLarge` check (clean 413) is what rejects an oversized file, not
65/// axum's raw limit (generic 400 `MultipartError`).
66const BODY_LIMIT_SLACK_MB: usize = 16;
67
68#[cfg(test)]
69mod parity_tests;
70
71#[cfg(test)]
72#[path = "cors_tests.rs"]
73mod cors_tests;
74
75use config::Config;
76use services::cache::DiskCache;
77
78/// Build CORS layer based on configuration.
79///
80/// If CORS_ORIGINS env var contains "*", allows any origin (use only in development).
81/// Otherwise, only allows specified origins.
82fn build_cors_layer(config: &Config) -> CorsLayer {
83    let is_permissive = config.cors_origins.iter().any(|o| o == "*");
84
85    if is_permissive {
86        tracing::warn!("CORS is set to permissive mode - use only in development");
87        CorsLayer::permissive()
88    } else {
89        tracing::info!("CORS configured for origins: {:?}", config.cors_origins);
90        let origins: Vec<HeaderValue> = config
91            .cors_origins
92            .iter()
93            .filter_map(|o| o.parse::<HeaderValue>().ok())
94            .collect();
95
96        CorsLayer::new()
97            .allow_origin(origins)
98            .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
99            .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT])
100            .max_age(Duration::from_secs(3600))
101    }
102}
103
104/// Startup log level for the memory-admission "gate off" branch in `main`,
105/// derived purely from the re-parsed `IFC_MEM_BUDGET_MB` env var (#1547).
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum LogDecision {
108    /// `IFC_MEM_BUDGET_MB=0`: the operator opted out of the memory gate
109    /// deliberately. Not a warning-worthy condition.
110    Info,
111    /// The env var is unset or unparseable AND the memory gate still ended
112    /// up off, i.e. auto-detection found no readable ceiling (non-Linux
113    /// host or `/proc` unavailable) — a silent OOM-risk degradation.
114    Warn,
115    /// A positive budget was explicitly requested. `main`'s call site only
116    /// reaches `memory_admission_log_level` from inside the
117    /// `config.mem_budget_mb == 0` branch, where this variant can never
118    /// actually occur (an explicit positive `IFC_MEM_BUDGET_MB` resolves
119    /// `config.mem_budget_mb` to that same positive value, see
120    /// `mem_policy::resolve_mem_budget_mb`). Kept so the function is total
121    /// over its input and independently unit-testable.
122    Active,
123}
124
125/// Pure decision for [`LogDecision`] from the resolved `IFC_MEM_BUDGET_MB`
126/// env var: `None` (unset/unparseable), `Some(0)` (explicit opt-out), or
127/// `Some(n)` with `n > 0` (an explicit positive budget).
128fn memory_admission_log_level(budget_mb: Option<u64>) -> LogDecision {
129    match budget_mb {
130        Some(0) => LogDecision::Info,
131        Some(_) => LogDecision::Active,
132        None => LogDecision::Warn,
133    }
134}
135
136/// Application state shared across handlers.
137#[derive(Clone)]
138pub struct AppState {
139    pub cache: Arc<DiskCache>,
140    pub config: Arc<Config>,
141    pub admission: Arc<admission::Admission>,
142    /// Cache keys with a data-model write in flight right now (#5129):
143    /// `get_data_model` answers 202 only for a key in this set, and 404 for
144    /// everything else it cannot read.
145    pub data_model_in_flight: Arc<in_flight::InFlightKeys>,
146}
147
148/// Build the application router with all routes and middleware.
149///
150/// Extracted from `main` so integration tests can exercise the full route
151/// table in-process (via `tower`'s `oneshot`) without binding a socket.
152// `TimeoutLayer::new` is deprecated in favor of `with_status_code`, but keeping
153// the existing default-status behavior here; revisit when tower-http is bumped.
154#[allow(deprecated)]
155fn build_router(state: AppState) -> Router {
156    let config = state.config.clone();
157
158    // Open routes: liveness/info probes are always reachable, even when an
159    // API token is configured (so health checks keep working).
160    let open_routes = Router::new()
161        // Root endpoint - API information
162        .route("/", get(routes::health::info))
163        // Health check (liveness: static, never load-gated - Railway's
164        // healthcheck points here and must not restart the box under load)
165        .route("/api/v1/health", get(routes::health::check))
166        // Readiness: 503 while the RSS breaker is shedding, so an external
167        // balancer can drain the instance without a restart loop.
168        .route("/api/v1/ready", get(routes::health::ready));
169
170    // Protected routes: the compute-heavy parse endpoints and cache reads. When
171    // `config.api_token` is set these require an `Authorization: Bearer <token>`
172    // header; otherwise the layer is a pass-through (see `middleware::auth`).
173    let protected_routes = Router::new()
174        // Parse endpoints
175        .route("/api/v1/parse", post(routes::parse::parse_full))
176        .route("/api/v1/parse/stream", post(routes::parse::parse_stream))
177        .route(
178            "/api/v1/parse/parquet-stream",
179            post(routes::parse::parse_parquet_stream),
180        )
181        .route(
182            "/api/v1/parse/metadata",
183            post(routes::parse::parse_metadata),
184        )
185        .route("/api/v1/parse/parquet", post(routes::parse::parse_parquet))
186        .route(
187            "/api/v1/parse/parquet/optimized",
188            post(routes::parse::parse_parquet_optimized),
189        )
190        .route(
191            "/api/v1/parse/data-model/{cache_key}",
192            get(routes::parse::get_data_model),
193        )
194        .route(
195            "/api/v1/parse/symbolic/{cache_key}",
196            get(routes::parse::get_symbolic),
197        )
198        // Cache endpoints
199        .route(
200            "/api/v1/cache/{key}",
201            get(routes::cache::get_cached).delete(routes::cache::delete_cached),
202        )
203        .route("/api/v1/cache/check/{hash}", get(routes::parse::check_cache))
204        .route(
205            "/api/v1/cache/geometry/{hash}",
206            get(routes::parse::get_cached_geometry),
207        )
208        // Prometheus text metrics (admission gauges + resident memory);
209        // registered only when enabled, protected by the same bearer layer.
210        .route("/api/v1/metrics", get(routes::metrics::metrics))
211        // Optional bearer-token auth (off unless `api_token` is configured).
212        .layer(axum::middleware::from_fn_with_state(
213            config.clone(),
214            middleware::auth::require_bearer_token,
215        ));
216
217    open_routes
218        .merge(protected_routes)
219        // Middleware (applies to all routes below this point)
220        // A hard backstop above `max_file_size_mb`, not the limit itself: multipart
221        // framing (boundary + field headers) adds a little overhead on top of the
222        // file payload, so a raw body limit equal to `max_file_size_mb` trips before
223        // `extract_file`'s own check on a file right at the ceiling. That raw-limit
224        // rejection surfaces as a generic `MultipartError` -> 400, not the clean
225        // `FileTooLarge` -> 413 below. Keep this layer only as defense-in-depth
226        // against grossly oversized bodies; the app-level check is the real gate.
227        .layer(DefaultBodyLimit::max(
228            (config.max_file_size_mb + BODY_LIMIT_SLACK_MB) * 1024 * 1024,
229        ))
230        // Note: Request decompression handled manually in extract_file() to support multipart
231        .layer(TimeoutLayer::new(Duration::from_secs(
232            config.request_timeout_secs,
233        )))
234        // One error body for every route (#5750): wraps the responses no
235        // handler writes (extractor rejections, unknown route, wrong method,
236        // the timeout above) in the `{"error", "code"}` envelope `ApiError`
237        // renders. Outside the timeout so its empty `408` is covered, and
238        // INSIDE compression so it only ever reads an uncompressed body.
239        .layer(axum::middleware::map_response(
240            middleware::error_envelope::envelope_errors,
241        ))
242        .layer(CompressionLayer::new()) // Compress responses (gzip)
243        .layer(TraceLayer::new_for_http())
244        .layer(build_cors_layer(&config))
245        // Outermost: turn any panic that unwinds out of a request handler into a
246        // 500 instead of propagating. Combined with the `server-release` profile
247        // (`panic = "unwind"`), this contains a malformed-IFC panic to the single
248        // offending request rather than crashing the whole server. Requires the
249        // `tower-http` `catch-panic` feature and a build profile that unwinds.
250        // Its `500` is rendered as the shared error envelope directly, since it
251        // sits outside the envelope layer; the panic payload stays in the log.
252        .layer(CatchPanicLayer::custom(
253            middleware::error_envelope::panic_response,
254        ))
255        .with_state(state)
256}
257
258#[tokio::main]
259async fn main() -> anyhow::Result<()> {
260    // Build-profile self-check, before anything else starts. The release
261    // pipeline runs the SHIPPED binary with this flag to prove it unwinds,
262    // because `CatchPanicLayer` below is inert under `panic = "abort"` and
263    // nothing about an aborting binary looks wrong until the first malformed
264    // upload kills the process for every tenant on it. Under `abort` this
265    // call never returns (SIGABRT), which is the failure the CI step reads.
266    if std::env::args().skip(1).any(|arg| arg == panic_strategy::SELFTEST_FLAG) {
267        if panic_strategy::panic_unwinds() {
268            println!("{}", panic_strategy::UNWIND_VERDICT);
269            return Ok(());
270        }
271        anyhow::bail!(
272            "panic-strategy: NOT unwind. This binary was built with a non-unwinding \
273             panic strategy, so CatchPanicLayer cannot contain a malformed-IFC panic \
274             to one request. Build with `--profile server-release`."
275        );
276    }
277
278    // Initialize logging
279    tracing_subscriber::fmt()
280        .with_env_filter(
281            std::env::var("RUST_LOG")
282                .unwrap_or_else(|_| "info,tower_http=debug,ifc_lite_server=debug".into()),
283        )
284        .pretty()
285        .init();
286
287    let config = Config::from_env();
288
289    tracing::info!(
290        port = config.port,
291        cache_dir = %config.cache_dir,
292        max_file_size_mb = config.max_file_size_mb,
293        worker_threads = config.worker_threads,
294        "Starting IFC-Lite Server"
295    );
296
297    if config.api_token.is_some() {
298        tracing::info!(
299            "Bearer-token auth ENABLED on compute/parse routes (IFC_SERVER_API_TOKEN set); /api/v1/health stays open"
300        );
301    } else {
302        tracing::warn!(
303            "Server is UNAUTHENTICATED: parse/cache routes are open to anyone. Set IFC_SERVER_API_TOKEN to require an Authorization: Bearer <token> header, and/or front the deployment with an edge rate limiter."
304        );
305    }
306
307    // Initialize rayon thread pool
308    rayon::ThreadPoolBuilder::new()
309        .num_threads(config.worker_threads)
310        .build_global()
311        .expect("Failed to initialize rayon thread pool");
312
313    // Initialize cache
314    let cache = Arc::new(DiskCache::new(&config.cache_dir).await);
315
316    let admission = Arc::new(admission::Admission::new(admission::AdmissionCfg {
317        max_concurrent_parses: config.max_concurrent_parses,
318        mem_budget_bytes: config.mem_budget_mb as u64 * 1024 * 1024,
319        queue_depth: config.admission_queue_depth,
320        queue_timeout: Duration::from_secs(config.admission_queue_timeout_secs),
321        shed_pct: config.mem_shed_pct,
322    }));
323    admission::spawn_rss_sampler(Arc::clone(&admission));
324    if config.mem_budget_mb == 0 {
325        // Budget 0 = memory gate off. Two distinct causes, logged differently
326        // by `memory_admission_log_level` below (see its doc for the third,
327        // unreachable-here-but-testable case).
328        let budget_env = std::env::var("IFC_MEM_BUDGET_MB")
329            .ok()
330            .and_then(|v| v.parse::<u64>().ok());
331        match memory_admission_log_level(budget_env) {
332            LogDecision::Info => {
333                tracing::info!(
334                    max_concurrent_parses = config.max_concurrent_parses,
335                    "Memory admission disabled via IFC_MEM_BUDGET_MB=0 (opt-out); only the CPU concurrency gate applies."
336                );
337            }
338            LogDecision::Warn | LogDecision::Active => {
339                tracing::warn!(
340                    max_concurrent_parses = config.max_concurrent_parses,
341                    "Memory admission is OFF (no readable memory ceiling: non-Linux host or /proc unavailable): concurrent large uploads can OOM this replica. Set IFC_MEM_BUDGET_MB, or run under a cgroup memory limit."
342                );
343            }
344        }
345    } else {
346        tracing::info!(
347            max_concurrent_parses = config.max_concurrent_parses,
348            mem_budget_mb = config.mem_budget_mb,
349            queue_depth = config.admission_queue_depth,
350            queue_timeout_secs = config.admission_queue_timeout_secs,
351            shed_pct = config.mem_shed_pct,
352            "Admission control active (byte budget + RSS breaker)"
353        );
354    }
355
356    let state = AppState {
357        cache,
358        config: Arc::new(config.clone()),
359        admission,
360        data_model_in_flight: Arc::new(in_flight::InFlightKeys::default()),
361    };
362
363    // Build router
364    let app = build_router(state);
365
366    let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
367    tracing::info!("Listening on http://{}", addr);
368
369    let listener = tokio::net::TcpListener::bind(addr)
370        .await
371        .with_context(|| format!("failed to bind to {addr}"))?;
372    let listener = write_timeout::WriteTimeoutListener::new(
373        listener,
374        Duration::from_secs(config.stream_idle_timeout_secs),
375    );
376    axum::serve(listener, app)
377        .await
378        .context("server exited with an error")?;
379    Ok(())
380}
381
382#[cfg(test)]
383mod auth_and_cache_tests;
384
385#[cfg(test)]
386mod memory_admission_log_level_tests;