cortex/config.rs
1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Layered, runtime configuration for `CorTeX`.
9//!
10//! This replaces two prototype-era patterns: the **compile-time** `dotenv!` baking of the database
11//! URL into the binary (so the deployment target could not change without a rebuild), and ad-hoc
12//! hard-coded constants for the dispatcher ports.
13//!
14//! Values are resolved with the following precedence (lowest to highest):
15//! 1. built-in [`Default`] values,
16//! 2. an optional `cortex.toml` file in the working directory,
17//! 3. `CORTEX_`-prefixed environment variables (nested with `__`, e.g.
18//! `CORTEX_DISPATCHER__SOURCE_PORT`),
19//! 4. the legacy `DATABASE_URL` / `TEST_DATABASE_URL` variables (also read from a local `.env`),
20//! which take final precedence so existing deployments keep working unchanged.
21//!
22//! Access the process-wide configuration through [`config()`].
23
24use figment::{
25 Figment,
26 providers::{Env, Format, Serialized, Toml},
27};
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::sync::LazyLock;
31
32/// Database connection settings.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DatabaseConfig {
35 /// Connection URL for the production database.
36 pub url: String,
37 /// Connection URL for the test database (used by the integration test-suite).
38 pub test_url: String,
39 /// Maximum size of the web frontend connection pool. Sized for the expected load (~2 admins +
40 /// 20 users; the ~200 workers speak ZeroMQ to the dispatcher, not Postgres) within PostgreSQL's
41 /// default `max_connections` of 100, which is shared with the dispatcher.
42 pub pool_size: u32,
43}
44impl Default for DatabaseConfig {
45 fn default() -> Self {
46 DatabaseConfig {
47 url: "postgres://cortex:cortex@localhost/cortex".to_string(),
48 test_url: "postgres://cortex_tester:cortex_tester@localhost/cortex_tester".to_string(),
49 pool_size: 32,
50 }
51 }
52}
53
54/// ZeroMQ dispatcher settings.
55#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
56pub struct DispatcherConfig {
57 /// Port the ventilator listens on for worker task requests.
58 pub source_port: usize,
59 /// Port the sink listens on for worker results.
60 pub result_port: usize,
61 /// Batch size for task-store queue requests (also the in-memory dispatch queue size).
62 ///
63 /// Must never exceed PostgreSQL's `max_locks_per_transaction` setting.
64 pub queue_size: usize,
65 /// Size of an individual ZeroMQ message chunk, in bytes.
66 pub message_size: usize,
67 /// Backpressure threshold: the maximum number of in-flight (dispatched-but-unfinished) tasks the
68 /// ventilator tolerates before it stops leasing new work and mock-replies to requesting workers
69 /// (which back off and retry). This bounds the in-flight set so it drains via the sink as
70 /// results return, instead of growing toward the hard panic bound
71 /// [`crate::dispatcher::server::PROGRESS_QUEUE_HARD_LIMIT`] — graceful degradation under
72 /// overload rather than a crash (KNOWN_ISSUES D-6). Keep it well below that hard bound to
73 /// leave recovery headroom. In steady state the in-flight set is ~the worker count (~200), so
74 /// the default leaves a wide margin.
75 pub max_in_flight: usize,
76 /// How often (seconds) the finalize thread refreshes the `report_summary` rollup *regardless of
77 /// drain*, bounding report staleness while a long run is in flight (a conversion run can take
78 /// weeks, so drain-only refresh is not enough). This is the automatic freshness guarantee; with
79 /// `REFRESH ... CONCURRENTLY` the rebuild no longer blocks readers, so it is cheap to run often.
80 /// The cost is one rebuild's DB load per interval (a few minutes at production scale). Default
81 /// 1h.
82 pub report_refresh_interval_seconds: u64,
83 /// **Hard cap** on the byte size of a single worker result the sink will write to `/data`. A
84 /// reply that exceeds it is **rejected** (the partial file is removed, the rest of the
85 /// multipart message is drained frame-by-frame to keep the socket in sync, and the task is
86 /// marked `Invalid`) rather than allowed to fill the disk — protecting the shared filesystem
87 /// from a runaway worker. We accept genuinely large jobs but draw the line here. Default 2
88 /// GiB.
89 pub max_result_bytes: usize,
90 /// **Sink archive-writer pool size.** Number of background threads the sink fans the blocking
91 /// `/data` result-archive writes out to (dispatcher rationalization phase 3, closes D-7). The
92 /// sink's single ZMQ-PULL receive loop reads each result's frames and hands them — task, then
93 /// streamed chunks, then a commit — to one of these writers, so *receiving* the next result is
94 /// no longer hostage to the current one's slow QLC-RAID6 write + `cortex.log` parse. Per-task
95 /// ordering is preserved (a task's frames go contiguously to one writer); fan-out is across
96 /// *different* tasks. Memory stays O(chunk) per writer (chunks are streamed and dropped, never
97 /// the whole archive resident) bounded by a small per-writer channel. Default **4** — a modest
98 /// decoupling that suits a box co-resident with ~200 workers; raise toward host cores if the
99 /// disk can absorb more concurrent writes. (1 is the floor; a single writer ≈ the legacy
100 /// inline behavior but still off the receive loop.)
101 pub sink_writers: usize,
102 /// **Finalize batch coalescing — size threshold (N).** The finalize thread accumulates returned
103 /// task reports and persists them to Postgres in **one** transaction per batch
104 /// ([`crate::backend::Backend::mark_done`]), flushing when the batch reaches this many reports —
105 /// or `finalize_flush_ms` elapses, whichever fires first. Larger N amortizes the DB round-trip
106 /// harder under load (fewer, bigger writes) at the cost of more rows per transaction. It is a
107 /// *ceiling* that mainly bites under burst/saturation; at steady-state load the time window
108 /// usually flushes first. Unlike `queue_size`, N is **not** bound by
109 /// `max_locks_per_transaction` (that limits *object* locks; `mark_done` takes only row locks),
110 /// so it can be large. Default **1024** — the empirical throughput knee from
111 /// `examples/dispatcher_bench.rs` (tasks/s rises to ~1024 then plateaus, and *regresses* by
112 /// ~4096 where a single transaction holds row locks long enough to stall the pipeline; see
113 /// `docs/DISPATCHER_BENCH.md`). 1024 also bounds worst-case crash re-work to ~1024 tasks.
114 /// (Dispatcher rationalization phase 2, `docs/archive/DISPATCHER_RATIONALIZATION.md`.)
115 pub finalize_batch_size: usize,
116 /// **Finalize batch coalescing — time threshold (T), milliseconds.** The maximum time a report
117 /// waits in an accumulating batch before it is flushed, bounding both report staleness and
118 /// worst-case crash **re-work**. An unflushed in-memory batch is never *lost* — its tasks stay
119 /// `Queued` and are recovered on restart — so T trades a little latency for far fewer DB writes,
120 /// not safety. At steady-state load this is usually the threshold that fires. Default 300 ms (at
121 /// ~200 tasks/s it coalesces ~60 tasks per write instead of one write per task, for a few
122 /// hundred ms of staleness).
123 pub finalize_flush_ms: u64,
124 /// **Lease / visibility timeout (seconds).** Base deadline for a dispatched (in-flight) task to
125 /// return a result before the reaper re-leases it. The effective per-task deadline backs off
126 /// with retries — `(retries + 1) × lease_timeout_seconds` from dispatch — so a task that keeps
127 /// timing out waits progressively longer rather than re-leasing ever-faster
128 /// ([`crate::helpers::TaskProgress::expected_at`]). This is the correctness net for a *silently
129 /// dead / half-open* worker (no ZMTP heartbeat needed): its task is recovered once the lease
130 /// lapses. Default **240** — just above the worker's hard per-document timeout (the CorTeX
131 /// `latexml` fleet caps each conversion at 180 s and *hard-kills* the process on overrun), with
132 /// a 60 s margin, which bounds any single conversion's runtime. With that cap, a lease expiry
133 /// reliably means the
134 /// worker **died** (timeout / OOM kill) on an unprocessable paper, so prompt re-lease — and,
135 /// after `MAX_DISPATCH_RETRIES`, dead-letter to `Fatal` — recovers the task *within the run*
136 /// instead of stranding it for an hour (orphaned-lease tail observed at scale). Raise it only
137 /// for a service whose workers have **unbounded** runtime (no per-task timeout). Paired with
138 /// `reap_interval_seconds` (how often the sweep runs).
139 pub lease_timeout_seconds: i64,
140 /// **Reaper sweep interval (seconds).** How often the ventilator scans the in-flight set for
141 /// tasks past their `lease_timeout_seconds` deadline and re-leases / dead-letters them.
142 /// Decoupled from the request path so the in-flight set drains even under sustained
143 /// backpressure (KNOWN_ISSUES D-6). Kept well below the lease timeout so an expired task is
144 /// recovered promptly without scanning the set on every request. Default **60** s. (Lowering
145 /// both this and `lease_timeout_seconds` is what lets a fast chaos test exercise reaper-based
146 /// recovery in seconds instead of the hour-scale production timing.)
147 pub reap_interval_seconds: i64,
148 /// **Late-result window (seconds)** (KNOWN_ISSUES D-26). A task the reaper re-leases or
149 /// dead-letters (`never_completed_with_retries`) can still get the result of an attempt whose
150 /// lease expired: a slow worker, a backed-up sink, or the worker harness reporting a child it
151 /// lost. For this long after the reaper releases it, the dispatcher remembers which worker each
152 /// attempt went to, and the sink accepts such a late result unless a newer attempt's result
153 /// already landed — so the real outcome replaces a `never_completed` Fatal and a requeued task
154 /// is not converted twice. The memory is per reaped task only (bounded, never the whole run);
155 /// a result past the window, or after a dispatcher restart, is discarded as before. `0` turns
156 /// late acceptance off. Default **3600** s.
157 pub late_result_window_seconds: i64,
158 /// **TCP keepalive idle (seconds) on the worker-facing ZMQ sockets** (ventilator + sink). After
159 /// this many idle seconds the OS begins probing the peer; this both keeps idle worker
160 /// connections alive across NAT/firewall idle-timeouts — essential when the ~200 remote
161 /// workers reach the dispatcher over an overlay/VPN or any NAT'd path, where an idle mapping
162 /// is otherwise silently dropped and the worker falls out of the fleet until it reconnects —
163 /// and lets the OS reap a genuinely dead peer so the ROUTER doesn't accumulate stale routes.
164 /// Task-recovery *correctness* does **not** depend on this (the lease reaper is that net, see
165 /// `lease_timeout_seconds`); it keeps the *fleet connected*. `<= 0` leaves the OS keepalive
166 /// default (effectively off). Default **120**, well under the common 5-minute NAT idle window.
167 /// (Probe interval/count are fixed sane
168 /// values in [`crate::dispatcher::server::apply_tcp_keepalive`].)
169 pub tcp_keepalive_idle_seconds: i32,
170 /// **Input-archive prefetcher pool size (D-20).** Number of background threads that warm the
171 /// next batch of task input archives into the OS page cache **ahead of dispatch**, so the
172 /// ventilator's inline `/data` read is served from RAM (~0.02 ms) instead of the cold
173 /// QLC-RAID6 platter (~10 ms median — the single-thread dispatch ceiling at full-arXiv scale
174 /// where the working set ≫ RAM). The warmers `open + read → discard`; the warmed bytes are
175 /// **reclaimable page cache**, not dispatcher RSS, so this cannot OOM (the kernel drops the
176 /// cache before the workers' anon memory). Graceful: a warm that lags or fails just leaves a
177 /// cold read, exactly as before. Default **8** (the warmers outpace dispatch ~8×, keeping the
178 /// window warm); **0 disables** (the ventilator reads inline, the pre-D-20 behavior).
179 pub input_prefetchers: usize,
180 /// **Per-entry prefetch cap (MiB).** Input archives larger than this are **not** prefetched —
181 /// they fall through to the ventilator's existing chunk-streaming read (O(chunk) resident, never
182 /// the whole file), so the rare 50–100 MB monster streams cold instead of doubling its bytes in
183 /// cache. Default **50**.
184 pub prefetch_max_entry_mb: usize,
185 /// **Total prefetch warm budget per batch (MiB).** The warmers stop warming a fetch batch once
186 /// their cumulative warmed bytes reach this, so a batch that clusters large entries can't dump
187 /// tens of GiB into page cache and churn out Postgres's working set; the batch's tail streams
188 /// cold. The typical batch (~`queue_size` × mean-entry ≈ a few hundred MiB) warms fully well
189 /// under this. Default **8192** (8 GiB).
190 pub prefetch_budget_mb: usize,
191}
192impl Default for DispatcherConfig {
193 fn default() -> Self {
194 DispatcherConfig {
195 source_port: 51695,
196 result_port: 51696,
197 queue_size: 800,
198 message_size: 100_000,
199 max_in_flight: 5000,
200 report_refresh_interval_seconds: 3600,
201 max_result_bytes: 2 * 1024 * 1024 * 1024, // 2 GiB
202 sink_writers: 4,
203 finalize_batch_size: 1024,
204 finalize_flush_ms: 300,
205 lease_timeout_seconds: 240,
206 reap_interval_seconds: 60,
207 late_result_window_seconds: 3600,
208 tcp_keepalive_idle_seconds: 120,
209 input_prefetchers: 8,
210 prefetch_max_entry_mb: 50,
211 prefetch_budget_mb: 8192,
212 }
213 }
214}
215
216/// Frontend authentication / secrets. The **only** source is the JSON token file
217/// ([`auth_file_path`] — `config.json` / `CORTEX_AUTH_FILE`); this is never read from or written to
218/// `cortex.toml` (the [`CortexConfig::auth`] field is `#[serde(skip)]`).
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct AuthConfig {
221 /// Password-like tokens mapped to a human-readable owner. The bootstrap / break-glass + agent
222 /// credential that gates every write action and the `/admin` sign-in, alongside passkeys (see
223 /// [`WebauthnConfig`] and `docs/archive/AAA_DESIGN.md`). Set via `cortex set-admin-token`.
224 pub rerun_tokens: HashMap<String, String>,
225}
226
227/// Passkey (**WebAuthn**) sign-in settings for the human admin UI
228/// (`docs/archive/WEBAUTHN_DESIGN.md`). The relying party is the CorTeX server itself — no external
229/// IdP, no per-deploy app registration. The admin token (see [`AuthConfig`]) remains the bootstrap
230/// / break-glass path; passkeys are the convenient day-to-day human sign-in once enrolled.
231#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
232pub struct WebauthnConfig {
233 /// Whether passkey sign-in is offered. Off until a deployment configures `rp_id`/`rp_origin` and
234 /// an admin enrolls a passkey (the token path keeps working regardless).
235 pub enabled: bool,
236 /// The relying-party id: the registrable domain passkeys are scoped to (host only, no scheme or
237 /// port), e.g. `localhost` for development or `corpora.latexml.rs` for the preview deployment.
238 pub rp_id: String,
239 /// The full origin the app is served from (scheme + host + optional port), e.g.
240 /// `http://localhost:8000` or `https://corpora.latexml.rs`. WebAuthn requires a secure context
241 /// (https) in production; `localhost` is exempt for development.
242 pub rp_origin: String,
243}
244impl Default for WebauthnConfig {
245 fn default() -> Self {
246 WebauthnConfig {
247 enabled: false,
248 rp_id: "localhost".to_string(),
249 rp_origin: "http://localhost:8000".to_string(),
250 }
251 }
252}
253
254/// On-disk asset locations, so the binary is not bound to its working directory.
255#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
256pub struct AssetsConfig {
257 /// Directory holding the Tera templates.
258 pub template_dir: String,
259 /// Directory holding the static public assets (css/js/images, favicon, robots.txt).
260 pub public_dir: String,
261}
262impl Default for AssetsConfig {
263 fn default() -> Self {
264 AssetsConfig {
265 template_dir: "templates".to_string(),
266 public_dir: "public".to_string(),
267 }
268 }
269}
270
271/// Background-job lifecycle settings (W-4 stall handling).
272#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
273pub struct JobsConfig {
274 /// A non-terminal job whose progress heartbeat (`updated_at`) has been silent this long is
275 /// presumed hung and reaped (marked `interrupted`) the next time the jobs surface is read.
276 /// Generous by default (2 h): a *progressing* job freshens its heartbeat each `step`, so only a
277 /// genuinely stuck body trips it. Raise it if you run long **single-statement** jobs that don't
278 /// heartbeat between steps (e.g. a multi-hour `REINDEX` of one huge table).
279 pub stale_timeout_seconds: i64,
280}
281impl Default for JobsConfig {
282 fn default() -> Self {
283 JobsConfig {
284 stale_timeout_seconds: 7200, // 2 h
285 }
286 }
287}
288
289/// Top-level `CorTeX` runtime configuration.
290#[derive(Debug, Clone, Default, Serialize, Deserialize)]
291pub struct CortexConfig {
292 /// Database connection settings.
293 pub database: DatabaseConfig,
294 /// ZeroMQ dispatcher settings.
295 pub dispatcher: DispatcherConfig,
296 /// Frontend authentication / secrets. **Loaded solely from the JSON token file** (`config.json`
297 /// / `CORTEX_AUTH_FILE`), never from `cortex.toml` — `#[serde(skip)]` keeps figment from
298 /// parsing an `[auth]` section, so tokens have one source of truth and there is no
299 /// file-vs-file override.
300 #[serde(skip)]
301 pub auth: AuthConfig,
302 /// On-disk asset locations.
303 pub assets: AssetsConfig,
304 /// Passkey (WebAuthn) sign-in settings.
305 pub webauthn: WebauthnConfig,
306 /// Background-job lifecycle settings.
307 pub jobs: JobsConfig,
308}
309
310impl CortexConfig {
311 /// Builds the layered configuration figment (defaults → `cortex.toml` → `CORTEX_` env).
312 /// Does not apply the legacy `DATABASE_URL` overrides; see [`CortexConfig::load`].
313 pub fn figment() -> Figment {
314 Figment::from(Serialized::defaults(CortexConfig::default()))
315 .merge(Toml::file(config_file_path()))
316 .merge(Env::prefixed("CORTEX_").split("__"))
317 }
318
319 /// Loads and validates the configuration, applying legacy environment overrides last.
320 /// Panics with a clear message if the configuration is malformed.
321 pub fn load() -> CortexConfig {
322 // Load a local `.env` into the process environment for backwards compatibility.
323 dotenvy::dotenv().ok();
324 let mut config: CortexConfig = CortexConfig::figment()
325 .extract()
326 .unwrap_or_else(|e| panic!("invalid CorTeX configuration: {e}"));
327 // The historic `DATABASE_URL` / `TEST_DATABASE_URL` variables remain authoritative when set,
328 // so existing `.env`-based deployments behave exactly as before.
329 if let Ok(url) = std::env::var("DATABASE_URL") {
330 config.database.url = url;
331 }
332 if let Ok(url) = std::env::var("TEST_DATABASE_URL") {
333 config.database.test_url = url;
334 }
335 // Tokens have ONE source of truth: the JSON token file (`config.json`, overridable via
336 // `CORTEX_AUTH_FILE` — so a deployment keeps its live token *outside the repo* at e.g.
337 // `/etc/cortex/config.json`, root-owned, while the repo `config.json` stays the demo/test
338 // fixture and the real token is never checked into git). It is **not** layered with a
339 // `cortex.toml [auth]` section: `auth` is `#[serde(skip)]`, so figment never parses one and
340 // there is no file-vs-file override to reason about. Read fresh here; `cortex set-admin-token`
341 // / `revoke-token` write this same file. (The prototype's `captcha_secret` is gone — bot
342 // protection is a deployment concern, an Anubis reverse proxy, not framework code; see
343 // docs/DEPLOYMENT.md.)
344 let auth_file = auth_file_path();
345 if let Ok(text) = std::fs::read_to_string(&auth_file) {
346 match serde_json::from_str::<TokenFile>(&text) {
347 Ok(parsed) => config.auth.rerun_tokens = parsed.rerun_tokens,
348 Err(e) => eprintln!("-- ignoring malformed {}: {e}", auth_file.display()),
349 }
350 }
351 config
352 }
353}
354
355/// On-disk shape of the JSON token file (`config.json` / `CORTEX_AUTH_FILE`) — the **single source
356/// of truth** for `rerun_tokens`. Read by the config loader and read/modified/written by
357/// `cortex set-admin-token` / `revoke-token`. Extra fields (e.g. the removed `captcha_secret`) are
358/// ignored.
359#[derive(Deserialize, Serialize, Default)]
360pub(crate) struct TokenFile {
361 /// `token → owner` map; the bearer credentials the frontend resolves on every gated request.
362 #[serde(default)]
363 pub(crate) rerun_tokens: HashMap<String, String>,
364}
365
366/// Serializes the non-secret configuration sections (everything except the `auth` secrets) to TOML.
367/// Shared by `cortex init`'s config scaffold and the Settings write path so the on-disk shape is
368/// identical.
369pub fn to_persisted_toml(config: &CortexConfig) -> Result<String, toml::ser::Error> {
370 #[derive(Serialize)]
371 struct Persisted<'a> {
372 database: &'a DatabaseConfig,
373 dispatcher: &'a DispatcherConfig,
374 assets: &'a AssetsConfig,
375 webauthn: &'a WebauthnConfig,
376 jobs: &'a JobsConfig,
377 }
378 toml::to_string_pretty(&Persisted {
379 database: &config.database,
380 dispatcher: &config.dispatcher,
381 assets: &config.assets,
382 webauthn: &config.webauthn,
383 jobs: &config.jobs,
384 })
385}
386
387/// The path of the optional `cortex.toml` configuration file, read and written by both the loader
388/// and the Settings write path. Overridable via the `CORTEX_CONFIG_FILE` environment variable.
389pub fn config_file_path() -> std::path::PathBuf {
390 std::env::var("CORTEX_CONFIG_FILE")
391 .map(std::path::PathBuf::from)
392 .unwrap_or_else(|_| std::path::PathBuf::from("cortex.toml"))
393}
394
395/// The path of the **token file** — the JSON holding `rerun_tokens` (admin/agent credentials). It
396/// is **gitignored** and scaffolded by `cortex init`; the tracked `config.example.json` is only a
397/// template. Defaults to `config.json` in the working directory, **overridable via
398/// `CORTEX_AUTH_FILE`** so a deployment keeps its live token *outside the repo* (e.g.
399/// `/etc/cortex/config.json`, root-owned) and the repo copy stays the demo/test fixture — the live
400/// secret is never checked in.
401pub fn auth_file_path() -> std::path::PathBuf {
402 std::env::var("CORTEX_AUTH_FILE")
403 .map(std::path::PathBuf::from)
404 .unwrap_or_else(|_| std::path::PathBuf::from("config.json"))
405}
406
407/// Returns the process-wide, lazily-loaded configuration.
408pub fn config() -> &'static CortexConfig {
409 static CONFIG: LazyLock<CortexConfig> = LazyLock::new(CortexConfig::load);
410 &CONFIG
411}