Skip to main content

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}