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  /// **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}