Skip to main content

ursula_config/
config.rs

1use std::path::PathBuf;
2
3use serde::Deserialize;
4use serde::Serialize;
5
6use crate::human::HumanDuration;
7use crate::human::HumanSize;
8
9/// Cold-storage backend selector.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ColdBackend {
13    #[default]
14    #[serde(alias = "disabled", alias = "off")]
15    None,
16    #[serde(alias = "mem", alias = "inmem")]
17    Memory,
18    S3,
19}
20
21/// Raft WAL persistence backend selector.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum WalBackend {
25    #[default]
26    Memory,
27    Disk,
28}
29
30/// Raft snapshot store backend selector.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum RaftSnapshotBackend {
34    #[default]
35    #[serde(alias = "default", alias = "")]
36    Inline,
37    S3,
38}
39
40/// Top-level Ursula server configuration.
41///
42/// Populated from a TOML config file, an optional preset, and CLI overrides.
43#[derive(Debug, Clone, Default, Deserialize, Serialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct UrsulaConfig {
46    pub server: ServerConfig,
47    pub runtime: RuntimeConfig,
48    pub raft: RaftConfig,
49    pub storage: StorageConfig,
50    pub governance: GovernanceConfig,
51    pub observability: ObservabilityConfig,
52}
53
54/// HTTP server binding and admission settings.
55#[derive(Debug, Clone, Deserialize, Serialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct ServerConfig {
58    /// Public HTTP client API bind address.
59    pub listen: String,
60    /// Optional separate bind for the cluster / Raft gRPC plane.
61    /// When omitted, both planes share `listen`.
62    pub cluster_listen: Option<String>,
63    /// Admin-plane bind for mutating operator endpoints (raft ops,
64    /// maintenance drain, cold-flush trigger). Loopback by default so nodes
65    /// expose no cluster-mutation surface on the network; operator tooling
66    /// reaches it through an SSH/SSM/port-forward tunnel.
67    pub admin_listen: String,
68    /// Process-wide cap on accepted write body bytes held by the HTTP layer.
69    pub http_inflight_body_size: HumanSize,
70}
71
72impl Default for ServerConfig {
73    fn default() -> Self {
74        Self {
75            listen: "127.0.0.1:4437".to_string(),
76            cluster_listen: None,
77            admin_listen: "127.0.0.1:4438".to_string(),
78            http_inflight_body_size: HumanSize::mib(256),
79        }
80    }
81}
82
83/// Per-core runtime sizing and admission controls.
84#[derive(Debug, Clone, Deserialize, Serialize)]
85#[serde(default, deny_unknown_fields)]
86pub struct RuntimeConfig {
87    /// Number of CPU cores / tokio worker threads to use.
88    pub core_count: usize,
89    /// Soft RSS cap. When the process RSS exceeds this value, new writes are
90    /// rejected with HTTP 503. `None` disables the monitor.
91    pub node_memory_abort_cap_size: Option<HumanSize>,
92    /// Minimum payload size that triggers external cold-store staging instead
93    /// of inline hot-ring storage. `None` uses the default (1 MiB).
94    pub external_payload_min_size: Option<HumanSize>,
95    /// Max live-read waiters per core. `None` or `0` disables the limit.
96    pub live_read_max_waiters_per_core: Option<usize>,
97}
98
99impl Default for RuntimeConfig {
100    fn default() -> Self {
101        Self {
102            core_count: std::thread::available_parallelism()
103                .map(|n| n.get())
104                .unwrap_or(4),
105            node_memory_abort_cap_size: None,
106            external_payload_min_size: None,
107            live_read_max_waiters_per_core: Some(65_536),
108        }
109    }
110}
111
112/// Raft consensus and static-cluster networking configuration.
113#[derive(Debug, Clone, Deserialize, Serialize)]
114#[serde(default, deny_unknown_fields)]
115pub struct RaftConfig {
116    /// Unique node ID within the static gRPC Raft cluster.
117    /// Must be present in `peers` and must be non-zero.
118    pub node_id: u64,
119    /// Number of Raft groups (shards). Defaults to `core_count * 16`.
120    pub group_count: usize,
121    /// Per-group cap on raft-submitted-but-not-yet-applied payload bytes.
122    /// `None` or `0` disables the admission. Catches raft replication lag before
123    /// in-memory queues grow unbounded.
124    pub max_uncommitted_size_per_group: Option<HumanSize>,
125    /// Bootstrap the initial Raft membership once on startup.
126    pub init_membership: bool,
127    /// Bootstrap per-group Raft membership on startup.
128    pub init_membership_per_group: bool,
129    /// Raft WAL configuration.
130    pub wal: WalConfig,
131    /// Static gRPC Raft peers. Each entry maps a `node_id` to its gRPC URL.
132    pub peers: Vec<RaftPeerConfig>,
133    /// Optional per-group voter assignments.
134    ///
135    /// When empty (the default), every Raft group uses all peers as voters.
136    /// When supplied, every group in `0..group_count` must have an entry and
137    /// each entry's voters must be a non-empty subset of `peers`.
138    #[serde(default)]
139    pub groups: Vec<RaftGroupConfig>,
140    /// How long a restarting node waits to observe an already-established
141    /// (or freshly re-elected) leader before deciding the group is truly new
142    /// and bootstrapping it. Must exceed the election window.
143    pub rejoin_probe: HumanDuration,
144    /// Timeout for probing static peers during bootstrap before logging a
145    /// warning. Continues retrying indefinitely.
146    pub bootstrap_peer_probe: HumanDuration,
147    /// Interval between static-peer reachability probes during bootstrap.
148    pub bootstrap_peer_probe_interval: HumanDuration,
149    /// gRPC connect timeout when probing static peers.
150    pub bootstrap_peer_connect: HumanDuration,
151    /// OpenRaft's `install_snapshot_timeout` covers the whole FullSnapshot RPC.
152    /// The receiver downloads and installs the referenced object before
153    /// replying, so this must be comfortably above the S3 per-attempt timeout
154    /// plus retries.
155    pub install_snapshot_timeout: HumanDuration,
156    /// Directory for memory-bootstrap marker files. When set, each group
157    /// writes a marker after successful membership initialization. On restart,
158    /// a marked memory group rejoins an observed leader or reinitializes
159    /// volatile membership if no leader exists.
160    pub memory_bootstrap_marker_dir: Option<PathBuf>,
161    /// Consecutive gRPC RPC failures before forcing a transport reconnect.
162    pub grpc_reconnect_after_failures: usize,
163    /// Max concurrent snapshot builds across all groups on this node.
164    pub snapshot_build_max_concurrency: usize,
165    /// Max concurrent snapshot installs across all groups on this node.
166    pub snapshot_install_max_concurrency: usize,
167}
168
169impl Default for RaftConfig {
170    fn default() -> Self {
171        Self {
172            node_id: 0,
173            group_count: std::thread::available_parallelism()
174                .map(|n| n.get().saturating_mul(16).max(1))
175                .unwrap_or(16),
176            max_uncommitted_size_per_group: None,
177            init_membership: false,
178            init_membership_per_group: false,
179            wal: WalConfig::default(),
180            peers: Vec::new(),
181            groups: Vec::new(),
182            rejoin_probe: HumanDuration::sec(6),
183            bootstrap_peer_probe: HumanDuration::sec(60),
184            bootstrap_peer_probe_interval: HumanDuration::milli(250),
185            bootstrap_peer_connect: HumanDuration::milli(500),
186            install_snapshot_timeout: HumanDuration::sec(120),
187            memory_bootstrap_marker_dir: None,
188            grpc_reconnect_after_failures: 8,
189            snapshot_build_max_concurrency: 1,
190            snapshot_install_max_concurrency: 1,
191        }
192    }
193}
194
195/// Raft write-ahead log configuration.
196#[derive(Debug, Clone, Deserialize, Serialize)]
197#[serde(default, deny_unknown_fields)]
198pub struct WalConfig {
199    /// WAL persistence backend.
200    pub backend: WalBackend,
201    /// Directory for on-disk WAL files. Required when `backend` is `Disk`.
202    pub path: Option<PathBuf>,
203}
204
205impl WalConfig {
206    /// Resolved on-disk log directory for the Raft WAL.
207    ///
208    /// When `backend` is `Disk` and `path` is set, appends the legacy
209    /// `raft-log` subdirectory so that existing data directories continue
210    /// to work after the config refactor.
211    pub fn resolved_path(&self) -> Option<PathBuf> {
212        match self.backend {
213            WalBackend::Memory => None,
214            WalBackend::Disk => self.path.as_ref().map(|p| p.join("raft-log")),
215        }
216    }
217}
218
219impl Default for WalConfig {
220    fn default() -> Self {
221        Self {
222            backend: WalBackend::Memory,
223            path: None,
224        }
225    }
226}
227
228/// A single static gRPC Raft peer.
229#[derive(Debug, Clone, Deserialize, Serialize)]
230#[serde(deny_unknown_fields)]
231pub struct RaftPeerConfig {
232    /// Peer node ID.
233    pub node_id: u64,
234    /// Peer gRPC URL.
235    pub url: String,
236}
237
238/// Per-group voter assignment for heterogeneous static clusters.
239///
240/// When omitted (the default), every group uses all peers as voters.
241#[derive(Debug, Clone, Deserialize, Serialize)]
242#[serde(deny_unknown_fields)]
243pub struct RaftGroupConfig {
244    /// Raft group ID.
245    pub raft_group_id: u32,
246    /// Node IDs that are voters for this group.
247    pub voters: Vec<u64>,
248}
249
250/// Storage tier configuration.
251#[derive(Debug, Clone, Default, Deserialize, Serialize)]
252#[serde(default, deny_unknown_fields)]
253pub struct StorageConfig {
254    /// Cold-tier (opendal-backed object store) configuration.
255    pub cold: ColdConfig,
256    /// Raft snapshot store configuration.
257    pub snapshot: RaftSnapshotConfig,
258}
259
260/// Cold-tier flush, GC, and cache configuration.
261#[derive(Debug, Clone, Deserialize, Serialize)]
262#[serde(default, deny_unknown_fields)]
263pub struct ColdConfig {
264    /// Cold-storage backend.
265    pub backend: ColdBackend,
266    /// Root prefix for cold-storage objects (e.g. S3 prefix or local dir).
267    pub root: Option<String>,
268    /// S3-specific connection and credential settings.
269    /// Required when `backend` is `S3`.
270    pub s3: Option<S3Config>,
271    /// Optional cold-read cache.
272    pub cache: Option<ColdCacheConfig>,
273    /// Interval between periodic cold-flush passes. Must be non-zero.
274    pub flush_interval: HumanDuration,
275    /// Target number of hot bytes to flush per group per pass.
276    pub flush_size: HumanSize,
277    /// Minimum hot bytes a group must have before it is eligible for flush.
278    /// Falls back to [`flush_size`](Self::flush_size) when unset.
279    pub flush_min_hot_size: Option<HumanSize>,
280    /// Upper bound on bytes flushed per group per pass.
281    /// Falls back to [`flush_size`](Self::flush_size) when unset.
282    pub flush_max_size: Option<HumanSize>,
283    /// Max groups flushed concurrently.
284    pub flush_max_concurrency: usize,
285    /// Per-group hot-size cap. When a group's hot bytes exceed this, new
286    /// writes are rejected with HTTP 503. `None` or `0` disables the admission.
287    pub max_hot_size_per_group: Option<HumanSize>,
288    /// Interval between periodic cold-gc passes. Must be non-zero.
289    pub gc_interval: HumanDuration,
290    /// Max GC entries to process per group per pass.
291    pub gc_max_entries: usize,
292}
293
294impl ColdConfig {
295    /// Minimum hot bytes a group must have before it is eligible for flush.
296    ///
297    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
298    /// supply an explicit value.
299    pub fn flush_min_hot_size(&self) -> HumanSize {
300        self.flush_min_hot_size.unwrap_or(self.flush_size)
301    }
302
303    /// Upper bound on bytes flushed per group per pass.
304    ///
305    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
306    /// supply an explicit value.
307    pub fn flush_max_size(&self) -> HumanSize {
308        self.flush_max_size.unwrap_or(self.flush_size)
309    }
310}
311
312impl Default for ColdConfig {
313    fn default() -> Self {
314        Self {
315            backend: ColdBackend::None,
316            root: None,
317            s3: None,
318            cache: None,
319            flush_interval: HumanDuration::sec(1),
320            flush_size: HumanSize::mib(8),
321            flush_min_hot_size: None,
322            flush_max_size: None,
323            flush_max_concurrency: 4,
324            max_hot_size_per_group: Some(HumanSize::mib(64)),
325            gc_interval: HumanDuration::sec(5),
326            gc_max_entries: 256,
327        }
328    }
329}
330
331/// S3 connection and credential settings.
332#[derive(Debug, Clone, Deserialize, Serialize)]
333#[serde(default, deny_unknown_fields)]
334pub struct S3Config {
335    /// S3 bucket name.
336    pub bucket: Option<String>,
337    /// S3 region.
338    pub region: Option<String>,
339    /// Custom S3 endpoint (for MinIO, etc.).
340    pub endpoint: Option<String>,
341    /// S3 access key ID.
342    pub access_key_id: Option<String>,
343    /// S3 secret access key.
344    pub secret_access_key: Option<String>,
345    /// Optional S3 session token.
346    pub session_token: Option<String>,
347    /// Per-S3-operation timeout.
348    pub timeout: HumanDuration,
349    /// Max retries per S3 operation.
350    pub max_retries: usize,
351    /// Timeout for S3 health probes.
352    pub probe_timeout: HumanDuration,
353    /// Consecutive probe failures before marking S3 unhealthy.
354    pub unhealthy_ticks: usize,
355    /// Consecutive probe successes before marking S3 healthy again.
356    pub heal_ticks: usize,
357}
358
359impl Default for S3Config {
360    fn default() -> Self {
361        Self {
362            bucket: None,
363            region: None,
364            endpoint: None,
365            access_key_id: None,
366            secret_access_key: None,
367            session_token: None,
368            timeout: HumanDuration::sec(10),
369            max_retries: 3,
370            probe_timeout: HumanDuration::sec(2),
371            unhealthy_ticks: 1,
372            heal_ticks: 2,
373        }
374    }
375}
376
377/// Cold-read cache sizing.
378#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
379#[serde(default, deny_unknown_fields)]
380pub struct ColdCacheConfig {
381    /// Max cache size in bytes.
382    pub max_size: HumanSize,
383    /// Cache block size in bytes.
384    pub block_size: HumanSize,
385    /// Number of blocks to read ahead on cache miss.
386    pub readahead_blocks: usize,
387}
388
389impl Default for ColdCacheConfig {
390    fn default() -> Self {
391        Self {
392            max_size: HumanSize::mib(256),
393            block_size: HumanSize::mib(1),
394            readahead_blocks: 4,
395        }
396    }
397}
398
399/// Raft snapshot store configuration.
400#[derive(Debug, Clone, Deserialize, Serialize)]
401#[serde(default, deny_unknown_fields)]
402pub struct RaftSnapshotConfig {
403    /// Snapshot store backend.
404    pub backend: RaftSnapshotBackend,
405    /// S3 prefix for snapshot objects. Used only when `backend` is `S3`.
406    pub s3_prefix: Option<String>,
407    /// Interval for the manual snapshot driver.
408    ///
409    /// When omitted, inline snapshot stores keep the manual driver disabled and
410    /// external snapshot stores use a 60s manual-driver default. Explicit `0s`
411    /// disables the manual driver and keeps openraft's default auto-policy.
412    pub drive_interval: Option<HumanDuration>,
413    /// Max concurrent snapshot flushes.
414    pub drive_flush_concurrency: usize,
415}
416
417impl Default for RaftSnapshotConfig {
418    fn default() -> Self {
419        Self {
420            backend: RaftSnapshotBackend::Inline,
421            s3_prefix: None,
422            drive_interval: None,
423            drive_flush_concurrency: 4,
424        }
425    }
426}
427
428/// Cluster governance and health-gate configuration.
429#[derive(Debug, Clone, Default, Deserialize, Serialize)]
430#[serde(default, deny_unknown_fields)]
431pub struct GovernanceConfig {
432    /// Leadership balancing configuration.
433    pub leadership_balance: LeadershipBalanceConfig,
434    /// Cluster egress probe configuration.
435    pub cluster_probe: ClusterProbeConfig,
436    /// Commit-stall watchdog configuration.
437    pub commit_stall: CommitStallConfig,
438    /// Cold-storage health gate configuration.
439    pub cold_health: ColdHealthConfig,
440}
441
442/// Leadership balancer tuning.
443#[derive(Debug, Clone, Deserialize, Serialize)]
444#[serde(default, deny_unknown_fields)]
445pub struct LeadershipBalanceConfig {
446    /// Tick interval for the leadership balancer.
447    pub interval: HumanDuration,
448    /// Max leader handoffs to attempt per tick.
449    pub max_per_tick: usize,
450    /// Timeout when querying peer shed state.
451    pub peer_timeout: HumanDuration,
452}
453
454impl Default for LeadershipBalanceConfig {
455    fn default() -> Self {
456        Self {
457            interval: HumanDuration::sec(5),
458            max_per_tick: 4,
459            peer_timeout: HumanDuration::milli(500),
460        }
461    }
462}
463
464/// Cluster egress probe tuning.
465#[derive(Debug, Clone, Deserialize, Serialize)]
466#[serde(default, deny_unknown_fields)]
467pub struct ClusterProbeConfig {
468    /// Tick interval for egress probes.
469    pub interval: HumanDuration,
470    /// Payload size for egress probe messages.
471    pub probe_size: HumanSize,
472    /// Timeout for individual egress probes.
473    pub timeout: HumanDuration,
474    /// Consecutive failed ticks before marking egress unhealthy.
475    pub unhealthy_ticks: usize,
476    /// Consecutive healthy ticks before clearing egress unhealthy.
477    pub heal_ticks: usize,
478}
479
480impl Default for ClusterProbeConfig {
481    fn default() -> Self {
482        Self {
483            interval: HumanDuration::milli(500),
484            probe_size: HumanSize::kib(64),
485            timeout: HumanDuration::milli(200),
486            unhealthy_ticks: 2,
487            heal_ticks: 6,
488        }
489    }
490}
491
492/// Commit-stall watchdog tuning.
493#[derive(Debug, Clone, Deserialize, Serialize)]
494#[serde(default, deny_unknown_fields)]
495pub struct CommitStallConfig {
496    /// Tick interval for the commit-stall watchdog.
497    pub interval: HumanDuration,
498    /// Duration a group must be stalled (`last_log_index > committed_index`)
499    /// before triggering a leader transfer.
500    pub threshold: HumanDuration,
501}
502
503impl Default for CommitStallConfig {
504    fn default() -> Self {
505        Self {
506            interval: HumanDuration::sec(2),
507            threshold: HumanDuration::sec(15),
508        }
509    }
510}
511
512/// Cold-storage health gate tuning.
513#[derive(Debug, Clone, Deserialize, Serialize)]
514#[serde(default, deny_unknown_fields)]
515pub struct ColdHealthConfig {
516    /// Tick interval for the cold-health gate.
517    pub interval: HumanDuration,
518    /// Consecutive unhealthy ticks before shedding leadership.
519    pub unhealthy_ticks: usize,
520    /// Consecutive healthy ticks before re-allowing leadership.
521    pub heal_ticks: usize,
522    /// High watermark for per-group hot bytes. Exceeding this contributes to
523    /// unhealthy.
524    pub hot_size_high: HumanSize,
525    /// Low watermark for per-group hot bytes. Dropping below this contributes
526    /// to healthy.
527    pub hot_size_low: HumanSize,
528    /// Error-count threshold per tick that marks cold as unhealthy.
529    pub errors_per_tick_high: usize,
530}
531
532impl Default for ColdHealthConfig {
533    fn default() -> Self {
534        Self {
535            interval: HumanDuration::sec(2),
536            unhealthy_ticks: 3,
537            heal_ticks: 5,
538            hot_size_high: HumanSize::mib(7),
539            hot_size_low: HumanSize::mib(4),
540            errors_per_tick_high: 1,
541        }
542    }
543}
544
545/// Observability and debugging features.
546#[derive(Debug, Clone, Default, Deserialize, Serialize)]
547#[serde(default, deny_unknown_fields)]
548pub struct ObservabilityConfig {
549    /// Enable tokio-console integration.
550    pub tokio_console: bool,
551}