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 /// **TCP keepalive idle (seconds) on the worker-facing ZMQ sockets** (ventilator + sink). After
149 /// this many idle seconds the OS begins probing the peer; this both keeps idle worker
150 /// connections alive across NAT/firewall idle-timeouts — essential when the ~200 remote
151 /// workers reach the dispatcher over an overlay/VPN or any NAT'd path, where an idle mapping
152 /// is otherwise silently dropped and the worker falls out of the fleet until it reconnects —
153 /// and lets the OS reap a genuinely dead peer so the ROUTER doesn't accumulate stale routes.
154 /// Task-recovery *correctness* does **not** depend on this (the lease reaper is that net, see
155 /// `lease_timeout_seconds`); it keeps the *fleet connected*. `<= 0` leaves the OS keepalive
156 /// default (effectively off). Default **120**, well under the common 5-minute NAT idle window.
157 /// (Probe interval/count are fixed sane
158 /// values in [`crate::dispatcher::server::apply_tcp_keepalive`].)
159 pub tcp_keepalive_idle_seconds: i32,
160 /// **Input-archive prefetcher pool size (D-20).** Number of background threads that warm the
161 /// next batch of task input archives into the OS page cache **ahead of dispatch**, so the
162 /// ventilator's inline `/data` read is served from RAM (~0.02 ms) instead of the cold
163 /// QLC-RAID6 platter (~10 ms median — the single-thread dispatch ceiling at full-arXiv scale
164 /// where the working set ≫ RAM). The warmers `open + read → discard`; the warmed bytes are
165 /// **reclaimable page cache**, not dispatcher RSS, so this cannot OOM (the kernel drops the
166 /// cache before the workers' anon memory). Graceful: a warm that lags or fails just leaves a
167 /// cold read, exactly as before. Default **8** (the warmers outpace dispatch ~8×, keeping the
168 /// window warm); **0 disables** (the ventilator reads inline, the pre-D-20 behavior).
169 pub input_prefetchers: usize,
170 /// **Per-entry prefetch cap (MiB).** Input archives larger than this are **not** prefetched —
171 /// they fall through to the ventilator's existing chunk-streaming read (O(chunk) resident, never
172 /// the whole file), so the rare 50–100 MB monster streams cold instead of doubling its bytes in
173 /// cache. Default **50**.
174 pub prefetch_max_entry_mb: usize,
175 /// **Total prefetch warm budget per batch (MiB).** The warmers stop warming a fetch batch once
176 /// their cumulative warmed bytes reach this, so a batch that clusters large entries can't dump
177 /// tens of GiB into page cache and churn out Postgres's working set; the batch's tail streams
178 /// cold. The typical batch (~`queue_size` × mean-entry ≈ a few hundred MiB) warms fully well
179 /// under this. Default **8192** (8 GiB).
180 pub prefetch_budget_mb: usize,
181}
182impl Default for DispatcherConfig {
183 fn default() -> Self {
184 DispatcherConfig {
185 source_port: 51695,
186 result_port: 51696,
187 queue_size: 800,
188 message_size: 100_000,
189 max_in_flight: 5000,
190 report_refresh_interval_seconds: 3600,
191 max_result_bytes: 2 * 1024 * 1024 * 1024, // 2 GiB
192 sink_writers: 4,
193 finalize_batch_size: 1024,
194 finalize_flush_ms: 300,
195 lease_timeout_seconds: 240,
196 reap_interval_seconds: 60,
197 tcp_keepalive_idle_seconds: 120,
198 input_prefetchers: 8,
199 prefetch_max_entry_mb: 50,
200 prefetch_budget_mb: 8192,
201 }
202 }
203}
204
205/// Frontend authentication / secrets. The **only** source is the JSON token file
206/// ([`auth_file_path`] — `config.json` / `CORTEX_AUTH_FILE`); this is never read from or written to
207/// `cortex.toml` (the [`CortexConfig::auth`] field is `#[serde(skip)]`).
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct AuthConfig {
210 /// Password-like tokens mapped to a human-readable owner. The bootstrap / break-glass + agent
211 /// credential that gates every write action and the `/admin` sign-in, alongside passkeys (see
212 /// [`WebauthnConfig`] and `docs/archive/AAA_DESIGN.md`). Set via `cortex set-admin-token`.
213 pub rerun_tokens: HashMap<String, String>,
214}
215
216/// Passkey (**WebAuthn**) sign-in settings for the human admin UI
217/// (`docs/archive/WEBAUTHN_DESIGN.md`). The relying party is the CorTeX server itself — no external
218/// IdP, no per-deploy app registration. The admin token (see [`AuthConfig`]) remains the bootstrap
219/// / break-glass path; passkeys are the convenient day-to-day human sign-in once enrolled.
220#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
221pub struct WebauthnConfig {
222 /// Whether passkey sign-in is offered. Off until a deployment configures `rp_id`/`rp_origin` and
223 /// an admin enrolls a passkey (the token path keeps working regardless).
224 pub enabled: bool,
225 /// The relying-party id: the registrable domain passkeys are scoped to (host only, no scheme or
226 /// port), e.g. `localhost` for development or `corpora.latexml.rs` for the preview deployment.
227 pub rp_id: String,
228 /// The full origin the app is served from (scheme + host + optional port), e.g.
229 /// `http://localhost:8000` or `https://corpora.latexml.rs`. WebAuthn requires a secure context
230 /// (https) in production; `localhost` is exempt for development.
231 pub rp_origin: String,
232}
233impl Default for WebauthnConfig {
234 fn default() -> Self {
235 WebauthnConfig {
236 enabled: false,
237 rp_id: "localhost".to_string(),
238 rp_origin: "http://localhost:8000".to_string(),
239 }
240 }
241}
242
243/// On-disk asset locations, so the binary is not bound to its working directory.
244#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
245pub struct AssetsConfig {
246 /// Directory holding the Tera templates.
247 pub template_dir: String,
248 /// Directory holding the static public assets (css/js/images, favicon, robots.txt).
249 pub public_dir: String,
250}
251impl Default for AssetsConfig {
252 fn default() -> Self {
253 AssetsConfig {
254 template_dir: "templates".to_string(),
255 public_dir: "public".to_string(),
256 }
257 }
258}
259
260/// Background-job lifecycle settings (W-4 stall handling).
261#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
262pub struct JobsConfig {
263 /// A non-terminal job whose progress heartbeat (`updated_at`) has been silent this long is
264 /// presumed hung and reaped (marked `interrupted`) the next time the jobs surface is read.
265 /// Generous by default (2 h): a *progressing* job freshens its heartbeat each `step`, so only a
266 /// genuinely stuck body trips it. Raise it if you run long **single-statement** jobs that don't
267 /// heartbeat between steps (e.g. a multi-hour `REINDEX` of one huge table).
268 pub stale_timeout_seconds: i64,
269}
270impl Default for JobsConfig {
271 fn default() -> Self {
272 JobsConfig {
273 stale_timeout_seconds: 7200, // 2 h
274 }
275 }
276}
277
278/// Top-level `CorTeX` runtime configuration.
279#[derive(Debug, Clone, Default, Serialize, Deserialize)]
280pub struct CortexConfig {
281 /// Database connection settings.
282 pub database: DatabaseConfig,
283 /// ZeroMQ dispatcher settings.
284 pub dispatcher: DispatcherConfig,
285 /// Frontend authentication / secrets. **Loaded solely from the JSON token file** (`config.json`
286 /// / `CORTEX_AUTH_FILE`), never from `cortex.toml` — `#[serde(skip)]` keeps figment from
287 /// parsing an `[auth]` section, so tokens have one source of truth and there is no
288 /// file-vs-file override.
289 #[serde(skip)]
290 pub auth: AuthConfig,
291 /// On-disk asset locations.
292 pub assets: AssetsConfig,
293 /// Passkey (WebAuthn) sign-in settings.
294 pub webauthn: WebauthnConfig,
295 /// Background-job lifecycle settings.
296 pub jobs: JobsConfig,
297}
298
299impl CortexConfig {
300 /// Builds the layered configuration figment (defaults → `cortex.toml` → `CORTEX_` env).
301 /// Does not apply the legacy `DATABASE_URL` overrides; see [`CortexConfig::load`].
302 pub fn figment() -> Figment {
303 Figment::from(Serialized::defaults(CortexConfig::default()))
304 .merge(Toml::file(config_file_path()))
305 .merge(Env::prefixed("CORTEX_").split("__"))
306 }
307
308 /// Loads and validates the configuration, applying legacy environment overrides last.
309 /// Panics with a clear message if the configuration is malformed.
310 pub fn load() -> CortexConfig {
311 // Load a local `.env` into the process environment for backwards compatibility.
312 dotenvy::dotenv().ok();
313 let mut config: CortexConfig = CortexConfig::figment()
314 .extract()
315 .unwrap_or_else(|e| panic!("invalid CorTeX configuration: {e}"));
316 // The historic `DATABASE_URL` / `TEST_DATABASE_URL` variables remain authoritative when set,
317 // so existing `.env`-based deployments behave exactly as before.
318 if let Ok(url) = std::env::var("DATABASE_URL") {
319 config.database.url = url;
320 }
321 if let Ok(url) = std::env::var("TEST_DATABASE_URL") {
322 config.database.test_url = url;
323 }
324 // Tokens have ONE source of truth: the JSON token file (`config.json`, overridable via
325 // `CORTEX_AUTH_FILE` — so a deployment keeps its live token *outside the repo* at e.g.
326 // `/etc/cortex/config.json`, root-owned, while the repo `config.json` stays the demo/test
327 // fixture and the real token is never checked into git). It is **not** layered with a
328 // `cortex.toml [auth]` section: `auth` is `#[serde(skip)]`, so figment never parses one and
329 // there is no file-vs-file override to reason about. Read fresh here; `cortex set-admin-token`
330 // / `revoke-token` write this same file. (The prototype's `captcha_secret` is gone — bot
331 // protection is a deployment concern, an Anubis reverse proxy, not framework code; see
332 // docs/DEPLOYMENT.md.)
333 let auth_file = auth_file_path();
334 if let Ok(text) = std::fs::read_to_string(&auth_file) {
335 match serde_json::from_str::<TokenFile>(&text) {
336 Ok(parsed) => config.auth.rerun_tokens = parsed.rerun_tokens,
337 Err(e) => eprintln!("-- ignoring malformed {}: {e}", auth_file.display()),
338 }
339 }
340 config
341 }
342}
343
344/// On-disk shape of the JSON token file (`config.json` / `CORTEX_AUTH_FILE`) — the **single source
345/// of truth** for `rerun_tokens`. Read by the config loader and read/modified/written by
346/// `cortex set-admin-token` / `revoke-token`. Extra fields (e.g. the removed `captcha_secret`) are
347/// ignored.
348#[derive(Deserialize, Serialize, Default)]
349pub(crate) struct TokenFile {
350 /// `token → owner` map; the bearer credentials the frontend resolves on every gated request.
351 #[serde(default)]
352 pub(crate) rerun_tokens: HashMap<String, String>,
353}
354
355/// Serializes the non-secret configuration sections (everything except the `auth` secrets) to TOML.
356/// Shared by `cortex init`'s config scaffold and the Settings write path so the on-disk shape is
357/// identical.
358pub fn to_persisted_toml(config: &CortexConfig) -> Result<String, toml::ser::Error> {
359 #[derive(Serialize)]
360 struct Persisted<'a> {
361 database: &'a DatabaseConfig,
362 dispatcher: &'a DispatcherConfig,
363 assets: &'a AssetsConfig,
364 webauthn: &'a WebauthnConfig,
365 jobs: &'a JobsConfig,
366 }
367 toml::to_string_pretty(&Persisted {
368 database: &config.database,
369 dispatcher: &config.dispatcher,
370 assets: &config.assets,
371 webauthn: &config.webauthn,
372 jobs: &config.jobs,
373 })
374}
375
376/// The path of the optional `cortex.toml` configuration file, read and written by both the loader
377/// and the Settings write path. Overridable via the `CORTEX_CONFIG_FILE` environment variable.
378pub fn config_file_path() -> std::path::PathBuf {
379 std::env::var("CORTEX_CONFIG_FILE")
380 .map(std::path::PathBuf::from)
381 .unwrap_or_else(|_| std::path::PathBuf::from("cortex.toml"))
382}
383
384/// The path of the **token file** — the JSON holding `rerun_tokens` (admin/agent credentials). It
385/// is **gitignored** and scaffolded by `cortex init`; the tracked `config.example.json` is only a
386/// template. Defaults to `config.json` in the working directory, **overridable via
387/// `CORTEX_AUTH_FILE`** so a deployment keeps its live token *outside the repo* (e.g.
388/// `/etc/cortex/config.json`, root-owned) and the repo copy stays the demo/test fixture — the live
389/// secret is never checked in.
390pub fn auth_file_path() -> std::path::PathBuf {
391 std::env::var("CORTEX_AUTH_FILE")
392 .map(std::path::PathBuf::from)
393 .unwrap_or_else(|_| std::path::PathBuf::from("config.json"))
394}
395
396/// Returns the process-wide, lazily-loaded configuration.
397pub fn config() -> &'static CortexConfig {
398 static CONFIG: LazyLock<CortexConfig> = LazyLock::new(CortexConfig::load);
399 &CONFIG
400}