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    Local,
38    S3,
39}
40
41/// Top-level Ursula server configuration.
42///
43/// Populated from a config file (TOML/JSON/YAML), an optional preset, and
44/// CLI overrides.
45#[derive(Debug, Clone, Default, Deserialize, Serialize)]
46#[serde(default, deny_unknown_fields)]
47pub struct UrsulaConfig {
48    pub server: ServerConfig,
49    pub runtime: RuntimeConfig,
50    pub raft: RaftConfig,
51    pub storage: StorageConfig,
52    pub governance: GovernanceConfig,
53    pub observability: ObservabilityConfig,
54}
55
56/// HTTP server binding and admission settings.
57#[derive(Debug, Clone, Deserialize, Serialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct ServerConfig {
60    /// Public HTTP client API bind address.
61    pub listen: String,
62    /// Optional separate bind for the cluster / Raft gRPC plane.
63    /// When omitted, both planes share `listen`.
64    pub cluster_listen: Option<String>,
65    /// Process-wide cap on accepted write body bytes held by the HTTP layer.
66    pub http_inflight_body_size: HumanSize,
67}
68
69impl Default for ServerConfig {
70    fn default() -> Self {
71        Self {
72            listen: "127.0.0.1:4437".to_string(),
73            cluster_listen: None,
74            http_inflight_body_size: HumanSize::mib(256),
75        }
76    }
77}
78
79/// Per-core runtime sizing and admission controls.
80#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(default, deny_unknown_fields)]
82pub struct RuntimeConfig {
83    /// Number of CPU cores / tokio worker threads to use.
84    pub core_count: usize,
85    /// Soft RSS cap. When the process RSS exceeds this value, new writes are
86    /// rejected with HTTP 503. `None` disables the monitor.
87    pub node_memory_abort_cap_size: Option<HumanSize>,
88    /// Minimum payload size that triggers external cold-store staging instead
89    /// of inline hot-ring storage. `None` uses the default (1 MiB).
90    pub external_payload_min_size: Option<HumanSize>,
91    /// Max live-read waiters per core. `None` or `0` disables the limit.
92    pub live_read_max_waiters_per_core: Option<usize>,
93}
94
95impl Default for RuntimeConfig {
96    fn default() -> Self {
97        Self {
98            core_count: std::thread::available_parallelism()
99                .map(|n| n.get())
100                .unwrap_or(4),
101            node_memory_abort_cap_size: None,
102            external_payload_min_size: None,
103            live_read_max_waiters_per_core: Some(65_536),
104        }
105    }
106}
107
108/// Raft consensus and static-cluster networking configuration.
109#[derive(Debug, Clone, Deserialize, Serialize)]
110#[serde(default, deny_unknown_fields)]
111pub struct RaftConfig {
112    /// Unique node ID within the static gRPC Raft cluster.
113    /// Must be present in `peers` and must be non-zero.
114    pub node_id: u64,
115    /// Number of Raft groups (shards). Defaults to `core_count * 16`.
116    pub group_count: usize,
117    /// Per-group cap on raft-submitted-but-not-yet-applied payload bytes.
118    /// `None` or `0` disables the admission. Catches raft replication lag before
119    /// in-memory queues grow unbounded.
120    pub max_uncommitted_size_per_group: Option<HumanSize>,
121    /// Bootstrap the initial Raft membership once on startup.
122    pub init_membership: bool,
123    /// Bootstrap per-group Raft membership on startup.
124    pub init_membership_per_group: bool,
125    /// Raft WAL configuration.
126    pub wal: WalConfig,
127    /// Static gRPC Raft peers. Each entry maps a `node_id` to its gRPC URL.
128    pub peers: Vec<RaftPeerConfig>,
129    /// Optional per-group voter assignments.
130    ///
131    /// When empty (the default), every Raft group uses all peers as voters.
132    /// When supplied, every group in `0..group_count` must have an entry and
133    /// each entry's voters must be a non-empty subset of `peers`.
134    #[serde(default)]
135    pub groups: Vec<RaftGroupConfig>,
136    /// How long a restarting node waits to observe an already-established
137    /// (or freshly re-elected) leader before deciding the group is truly new
138    /// and bootstrapping it. Must exceed the election window.
139    pub rejoin_probe: HumanDuration,
140    /// Timeout for probing static peers during bootstrap before logging a
141    /// warning. Continues retrying indefinitely.
142    pub bootstrap_peer_probe: HumanDuration,
143    /// Interval between static-peer reachability probes during bootstrap.
144    pub bootstrap_peer_probe_interval: HumanDuration,
145    /// gRPC connect timeout when probing static peers.
146    pub bootstrap_peer_connect: HumanDuration,
147    /// OpenRaft's `install_snapshot_timeout` covers the whole FullSnapshot RPC.
148    /// The receiver downloads and installs the referenced object before
149    /// replying, so this must be comfortably above the S3 per-attempt timeout
150    /// plus retries.
151    pub install_snapshot_timeout: HumanDuration,
152    /// Directory for memory-bootstrap marker files. When set, each group
153    /// writes a marker after successful membership initialization. On restart,
154    /// a marked memory group rejoins an observed leader or reinitializes
155    /// volatile membership if no leader exists.
156    pub memory_bootstrap_marker_dir: Option<PathBuf>,
157    /// Consecutive gRPC RPC failures before forcing a transport reconnect.
158    pub grpc_reconnect_after_failures: usize,
159    /// Max concurrent snapshot builds across all groups on this node.
160    pub snapshot_build_max_concurrency: usize,
161    /// Max concurrent snapshot installs across all groups on this node.
162    pub snapshot_install_max_concurrency: usize,
163}
164
165impl Default for RaftConfig {
166    fn default() -> Self {
167        Self {
168            node_id: 0,
169            group_count: std::thread::available_parallelism()
170                .map(|n| n.get().saturating_mul(16).max(1))
171                .unwrap_or(16),
172            max_uncommitted_size_per_group: None,
173            init_membership: false,
174            init_membership_per_group: false,
175            wal: WalConfig::default(),
176            peers: Vec::new(),
177            groups: Vec::new(),
178            rejoin_probe: HumanDuration::sec(6),
179            bootstrap_peer_probe: HumanDuration::sec(60),
180            bootstrap_peer_probe_interval: HumanDuration::milli(250),
181            bootstrap_peer_connect: HumanDuration::milli(500),
182            install_snapshot_timeout: HumanDuration::sec(120),
183            memory_bootstrap_marker_dir: None,
184            grpc_reconnect_after_failures: 8,
185            snapshot_build_max_concurrency: 1,
186            snapshot_install_max_concurrency: 1,
187        }
188    }
189}
190
191/// Raft write-ahead log configuration.
192#[derive(Debug, Clone, Deserialize, Serialize)]
193#[serde(default, deny_unknown_fields)]
194pub struct WalConfig {
195    /// WAL persistence backend.
196    pub backend: WalBackend,
197    /// Directory for on-disk WAL files. Required when `backend` is `Disk`.
198    pub path: Option<PathBuf>,
199}
200
201impl WalConfig {
202    /// Resolved on-disk log directory for the Raft WAL.
203    ///
204    /// When `backend` is `Disk` and `path` is set, appends the legacy
205    /// `raft-log` subdirectory so that existing data directories continue
206    /// to work after the config refactor.
207    pub fn resolved_path(&self) -> Option<PathBuf> {
208        match self.backend {
209            WalBackend::Memory => None,
210            WalBackend::Disk => self.path.as_ref().map(|p| p.join("raft-log")),
211        }
212    }
213}
214
215impl Default for WalConfig {
216    fn default() -> Self {
217        Self {
218            backend: WalBackend::Memory,
219            path: None,
220        }
221    }
222}
223
224/// A single static gRPC Raft peer.
225#[derive(Debug, Clone, Deserialize, Serialize)]
226#[serde(deny_unknown_fields)]
227pub struct RaftPeerConfig {
228    /// Peer node ID.
229    pub node_id: u64,
230    /// Peer gRPC URL.
231    pub url: String,
232}
233
234/// Per-group voter assignment for heterogeneous static clusters.
235///
236/// When omitted (the default), every group uses all peers as voters.
237#[derive(Debug, Clone, Deserialize, Serialize)]
238#[serde(deny_unknown_fields)]
239pub struct RaftGroupConfig {
240    /// Raft group ID.
241    pub raft_group_id: u32,
242    /// Node IDs that are voters for this group.
243    pub voters: Vec<u64>,
244}
245
246/// Storage tier configuration.
247#[derive(Debug, Clone, Default, Deserialize, Serialize)]
248#[serde(default, deny_unknown_fields)]
249pub struct StorageConfig {
250    /// Cold-tier (opendal-backed object store) configuration.
251    pub cold: ColdConfig,
252    /// Raft snapshot store configuration.
253    pub snapshot: RaftSnapshotConfig,
254}
255
256/// Cold-tier flush, GC, and cache configuration.
257#[derive(Debug, Clone, Deserialize, Serialize)]
258#[serde(default, deny_unknown_fields)]
259pub struct ColdConfig {
260    /// Cold-storage backend.
261    pub backend: ColdBackend,
262    /// Root prefix for cold-storage objects (e.g. S3 prefix or local dir).
263    pub root: Option<String>,
264    /// S3-specific connection and credential settings.
265    /// Required when `backend` is `S3`.
266    pub s3: Option<S3Config>,
267    /// Optional cold-read cache.
268    pub cache: Option<ColdCacheConfig>,
269    /// Interval between periodic cold-flush passes. Must be non-zero.
270    pub flush_interval: HumanDuration,
271    /// Target number of hot bytes to flush per group per pass.
272    pub flush_size: HumanSize,
273    /// Minimum hot bytes a group must have before it is eligible for flush.
274    /// Falls back to [`flush_size`](Self::flush_size) when unset.
275    pub flush_min_hot_size: Option<HumanSize>,
276    /// Upper bound on bytes flushed per group per pass.
277    /// Falls back to [`flush_size`](Self::flush_size) when unset.
278    pub flush_max_size: Option<HumanSize>,
279    /// Max groups flushed concurrently.
280    pub flush_max_concurrency: usize,
281    /// Per-group hot-size cap. When a group's hot bytes exceed this, new
282    /// writes are rejected with HTTP 503. `None` or `0` disables the admission.
283    pub max_hot_size_per_group: Option<HumanSize>,
284    /// Interval between periodic cold-gc passes. Must be non-zero.
285    pub gc_interval: HumanDuration,
286    /// Max GC entries to process per group per pass.
287    pub gc_max_entries: usize,
288}
289
290impl ColdConfig {
291    /// Minimum hot bytes a group must have before it is eligible for flush.
292    ///
293    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
294    /// supply an explicit value.
295    pub fn flush_min_hot_size(&self) -> HumanSize {
296        self.flush_min_hot_size.unwrap_or(self.flush_size)
297    }
298
299    /// Upper bound on bytes flushed per group per pass.
300    ///
301    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
302    /// supply an explicit value.
303    pub fn flush_max_size(&self) -> HumanSize {
304        self.flush_max_size.unwrap_or(self.flush_size)
305    }
306}
307
308impl Default for ColdConfig {
309    fn default() -> Self {
310        Self {
311            backend: ColdBackend::None,
312            root: None,
313            s3: None,
314            cache: None,
315            flush_interval: HumanDuration::sec(1),
316            flush_size: HumanSize::mib(8),
317            flush_min_hot_size: None,
318            flush_max_size: None,
319            flush_max_concurrency: 4,
320            max_hot_size_per_group: Some(HumanSize::mib(64)),
321            gc_interval: HumanDuration::sec(5),
322            gc_max_entries: 256,
323        }
324    }
325}
326
327/// S3 connection and credential settings.
328#[derive(Debug, Clone, Deserialize, Serialize)]
329#[serde(default, deny_unknown_fields)]
330pub struct S3Config {
331    /// S3 bucket name.
332    pub bucket: Option<String>,
333    /// S3 region.
334    pub region: Option<String>,
335    /// Custom S3 endpoint (for MinIO, etc.).
336    pub endpoint: Option<String>,
337    /// S3 access key ID.
338    pub access_key_id: Option<String>,
339    /// S3 secret access key.
340    pub secret_access_key: Option<String>,
341    /// Optional S3 session token.
342    pub session_token: Option<String>,
343    /// Per-S3-operation timeout.
344    pub timeout: HumanDuration,
345    /// Max retries per S3 operation.
346    pub max_retries: usize,
347    /// Timeout for S3 health probes.
348    pub probe_timeout: HumanDuration,
349    /// Consecutive probe failures before marking S3 unhealthy.
350    pub unhealthy_ticks: usize,
351    /// Consecutive probe successes before marking S3 healthy again.
352    pub heal_ticks: usize,
353}
354
355impl Default for S3Config {
356    fn default() -> Self {
357        Self {
358            bucket: None,
359            region: None,
360            endpoint: None,
361            access_key_id: None,
362            secret_access_key: None,
363            session_token: None,
364            timeout: HumanDuration::sec(10),
365            max_retries: 3,
366            probe_timeout: HumanDuration::sec(2),
367            unhealthy_ticks: 1,
368            heal_ticks: 2,
369        }
370    }
371}
372
373/// Cold-read cache sizing.
374#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
375#[serde(default, deny_unknown_fields)]
376pub struct ColdCacheConfig {
377    /// Max cache size in bytes.
378    pub max_size: HumanSize,
379    /// Cache block size in bytes.
380    pub block_size: HumanSize,
381    /// Number of blocks to read ahead on cache miss.
382    pub readahead_blocks: usize,
383}
384
385impl Default for ColdCacheConfig {
386    fn default() -> Self {
387        Self {
388            max_size: HumanSize::mib(256),
389            block_size: HumanSize::mib(1),
390            readahead_blocks: 4,
391        }
392    }
393}
394
395/// Raft snapshot store configuration.
396#[derive(Debug, Clone, Deserialize, Serialize)]
397#[serde(default, deny_unknown_fields)]
398pub struct RaftSnapshotConfig {
399    /// Snapshot store backend.
400    pub backend: RaftSnapshotBackend,
401    /// Root directory for local snapshot storage.
402    /// Required when `backend` is `Local`.
403    pub local_root: Option<PathBuf>,
404    /// S3 prefix for snapshot objects. Used only when `backend` is `S3`.
405    pub s3_prefix: Option<String>,
406    /// Interval for the manual snapshot driver.
407    ///
408    /// When omitted, inline snapshot stores keep the manual driver disabled and
409    /// external snapshot stores use a 60s manual-driver default. Explicit `0s`
410    /// disables the manual driver and keeps openraft's default auto-policy.
411    pub drive_interval: Option<HumanDuration>,
412    /// Max concurrent snapshot flushes.
413    pub drive_flush_concurrency: usize,
414}
415
416impl Default for RaftSnapshotConfig {
417    fn default() -> Self {
418        Self {
419            backend: RaftSnapshotBackend::Inline,
420            local_root: None,
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}