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;