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 so that
154    /// restart skips re-initialization.
155    pub memory_bootstrap_marker_dir: Option<PathBuf>,
156    /// Consecutive gRPC RPC failures before forcing a transport reconnect.
157    pub grpc_reconnect_after_failures: usize,
158    /// Max concurrent snapshot installs across all groups on this node.
159    pub snapshot_install_max_concurrency: usize,
160}
161
162impl Default for RaftConfig {
163    fn default() -> Self {
164        Self {
165            node_id: 0,
166            group_count: std::thread::available_parallelism()
167                .map(|n| n.get().saturating_mul(16).max(1))
168                .unwrap_or(16),
169            max_uncommitted_size_per_group: None,
170            init_membership: false,
171            init_membership_per_group: false,
172            wal: WalConfig::default(),
173            peers: Vec::new(),
174            groups: Vec::new(),
175            rejoin_probe: HumanDuration::sec(6),
176            bootstrap_peer_probe: HumanDuration::sec(60),
177            bootstrap_peer_probe_interval: HumanDuration::milli(250),
178            bootstrap_peer_connect: HumanDuration::milli(500),
179            install_snapshot_timeout: HumanDuration::sec(120),
180            memory_bootstrap_marker_dir: None,
181            grpc_reconnect_after_failures: 8,
182            snapshot_install_max_concurrency: 1,
183        }
184    }
185}
186
187/// Raft write-ahead log configuration.
188#[derive(Debug, Clone, Deserialize, Serialize)]
189#[serde(default, deny_unknown_fields)]
190pub struct WalConfig {
191    /// WAL persistence backend.
192    pub backend: WalBackend,
193    /// Directory for on-disk WAL files. Required when `backend` is `Disk`.
194    pub path: Option<PathBuf>,
195}
196
197impl WalConfig {
198    /// Resolved on-disk log directory for the Raft WAL.
199    ///
200    /// When `backend` is `Disk` and `path` is set, appends the legacy
201    /// `raft-log` subdirectory so that existing data directories continue
202    /// to work after the config refactor.
203    pub fn resolved_path(&self) -> Option<PathBuf> {
204        match self.backend {
205            WalBackend::Memory => None,
206            WalBackend::Disk => self.path.as_ref().map(|p| p.join("raft-log")),
207        }
208    }
209}
210
211impl Default for WalConfig {
212    fn default() -> Self {
213        Self {
214            backend: WalBackend::Memory,
215            path: None,
216        }
217    }
218}
219
220/// A single static gRPC Raft peer.
221#[derive(Debug, Clone, Deserialize, Serialize)]
222#[serde(deny_unknown_fields)]
223pub struct RaftPeerConfig {
224    /// Peer node ID.
225    pub node_id: u64,
226    /// Peer gRPC URL.
227    pub url: String,
228}
229
230/// Per-group voter assignment for heterogeneous static clusters.
231///
232/// When omitted (the default), every group uses all peers as voters.
233#[derive(Debug, Clone, Deserialize, Serialize)]
234#[serde(deny_unknown_fields)]
235pub struct RaftGroupConfig {
236    /// Raft group ID.
237    pub raft_group_id: u32,
238    /// Node IDs that are voters for this group.
239    pub voters: Vec<u64>,
240}
241
242/// Storage tier configuration.
243#[derive(Debug, Clone, Default, Deserialize, Serialize)]
244#[serde(default, deny_unknown_fields)]
245pub struct StorageConfig {
246    /// Cold-tier (opendal-backed object store) configuration.
247    pub cold: ColdConfig,
248    /// Raft snapshot store configuration.
249    pub snapshot: RaftSnapshotConfig,
250}
251
252/// Cold-tier flush, GC, and cache configuration.
253#[derive(Debug, Clone, Deserialize, Serialize)]
254#[serde(default, deny_unknown_fields)]
255pub struct ColdConfig {
256    /// Cold-storage backend.
257    pub backend: ColdBackend,
258    /// Root prefix for cold-storage objects (e.g. S3 prefix or local dir).
259    pub root: Option<String>,
260    /// S3-specific connection and credential settings.
261    /// Required when `backend` is `S3`.
262    pub s3: Option<S3Config>,
263    /// Optional cold-read cache.
264    pub cache: Option<ColdCacheConfig>,
265    /// Interval between periodic cold-flush passes. Must be non-zero.
266    pub flush_interval: HumanDuration,
267    /// Target number of hot bytes to flush per group per pass.
268    pub flush_size: HumanSize,
269    /// Minimum hot bytes a group must have before it is eligible for flush.
270    /// Falls back to [`flush_size`](Self::flush_size) when unset.
271    pub flush_min_hot_size: Option<HumanSize>,
272    /// Upper bound on bytes flushed per group per pass.
273    /// Falls back to [`flush_size`](Self::flush_size) when unset.
274    pub flush_max_size: Option<HumanSize>,
275    /// Max groups flushed concurrently.
276    pub flush_max_concurrency: usize,
277    /// Per-group hot-size cap. When a group's hot bytes exceed this, new
278    /// writes are rejected with HTTP 503. `None` or `0` disables the admission.
279    pub max_hot_size_per_group: Option<HumanSize>,
280    /// Interval between periodic cold-gc passes. Must be non-zero.
281    pub gc_interval: HumanDuration,
282    /// Max GC entries to process per group per pass.
283    pub gc_max_entries: usize,
284}
285
286impl ColdConfig {
287    /// Minimum hot bytes a group must have before it is eligible for flush.
288    ///
289    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
290    /// supply an explicit value.
291    pub fn flush_min_hot_size(&self) -> HumanSize {
292        self.flush_min_hot_size.unwrap_or(self.flush_size)
293    }
294
295    /// Upper bound on bytes flushed per group per pass.
296    ///
297    /// Falls back to [`flush_size`](Self::flush_size) when the user does not
298    /// supply an explicit value.
299    pub fn flush_max_size(&self) -> HumanSize {
300        self.flush_max_size.unwrap_or(self.flush_size)
301    }
302}
303
304impl Default for ColdConfig {
305    fn default() -> Self {
306        Self {
307            backend: ColdBackend::None,
308            root: None,
309            s3: None,
310            cache: None,
311            flush_interval: HumanDuration::sec(1),
312            flush_size: HumanSize::mib(8),
313            flush_min_hot_size: None,
314            flush_max_size: None,
315            flush_max_concurrency: 4,
316            max_hot_size_per_group: Some(HumanSize::mib(64)),
317            gc_interval: HumanDuration::sec(5),
318            gc_max_entries: 256,
319        }
320    }
321}
322
323/// S3 connection and credential settings.
324#[derive(Debug, Clone, Deserialize, Serialize)]
325#[serde(default, deny_unknown_fields)]
326pub struct S3Config {
327    /// S3 bucket name.
328    pub bucket: Option<String>,
329    /// S3 region.
330    pub region: Option<String>,
331    /// Custom S3 endpoint (for MinIO, etc.).
332    pub endpoint: Option<String>,
333    /// S3 access key ID.
334    pub access_key_id: Option<String>,
335    /// S3 secret access key.
336    pub secret_access_key: Option<String>,
337    /// Optional S3 session token.
338    pub session_token: Option<String>,
339    /// Per-S3-operation timeout.
340    pub timeout: HumanDuration,
341    /// Max retries per S3 operation.
342    pub max_retries: usize,
343    /// Timeout for S3 health probes.
344    pub probe_timeout: HumanDuration,
345    /// Consecutive probe failures before marking S3 unhealthy.
346    pub unhealthy_ticks: usize,
347    /// Consecutive probe successes before marking S3 healthy again.
348    pub heal_ticks: usize,
349}
350
351impl Default for S3Config {
352    fn default() -> Self {
353        Self {
354            bucket: None,
355            region: None,
356            endpoint: None,
357            access_key_id: None,
358            secret_access_key: None,
359            session_token: None,
360            timeout: HumanDuration::sec(10),
361            max_retries: 3,
362            probe_timeout: HumanDuration::sec(2),
363            unhealthy_ticks: 1,
364            heal_ticks: 2,
365        }
366    }
367}
368
369/// Cold-read cache sizing.
370#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
371#[serde(default, deny_unknown_fields)]
372pub struct ColdCacheConfig {
373    /// Max cache size in bytes.
374    pub max_size: HumanSize,
375    /// Cache block size in bytes.
376    pub block_size: HumanSize,
377    /// Number of blocks to read ahead on cache miss.
378    pub readahead_blocks: usize,
379}
380
381impl Default for ColdCacheConfig {
382    fn default() -> Self {
383        Self {
384            max_size: HumanSize::mib(256),
385            block_size: HumanSize::mib(1),
386            readahead_blocks: 4,
387        }
388    }
389}
390
391/// Raft snapshot store configuration.
392#[derive(Debug, Clone, Deserialize, Serialize)]
393#[serde(default, deny_unknown_fields)]
394pub struct RaftSnapshotConfig {
395    /// Snapshot store backend.
396    pub backend: RaftSnapshotBackend,
397    /// Root directory for local snapshot storage.
398    /// Required when `backend` is `Local`.
399    pub local_root: Option<PathBuf>,
400    /// S3 prefix for snapshot objects. Used only when `backend` is `S3`.
401    pub s3_prefix: Option<String>,
402    /// Interval for the manual snapshot driver.
403    ///
404    /// When omitted, inline snapshot stores keep the manual driver disabled and
405    /// external snapshot stores use a 60s manual-driver default. Explicit `0s`
406    /// disables the manual driver and keeps openraft's default auto-policy.
407    pub drive_interval: Option<HumanDuration>,
408    /// Max concurrent snapshot flushes.
409    pub drive_flush_concurrency: usize,
410}
411
412impl Default for RaftSnapshotConfig {
413    fn default() -> Self {
414        Self {
415            backend: RaftSnapshotBackend::Inline,
416            local_root: None,
417            s3_prefix: None,
418            drive_interval: None,
419            drive_flush_concurrency: 4,
420        }
421    }
422}
423
424/// Cluster governance and health-gate configuration.
425#[derive(Debug, Clone, Default, Deserialize, Serialize)]
426#[serde(default, deny_unknown_fields)]
427pub struct GovernanceConfig {
428    /// Leadership balancing configuration.
429    pub leadership_balance: LeadershipBalanceConfig,
430    /// Cluster egress probe configuration.
431    pub cluster_probe: ClusterProbeConfig,
432    /// Commit-stall watchdog configuration.
433    pub commit_stall: CommitStallConfig,
434    /// Cold-storage health gate configuration.
435    pub cold_health: ColdHealthConfig,
436}
437
438/// Leadership balancer tuning.
439#[derive(Debug, Clone, Deserialize, Serialize)]
440#[serde(default, deny_unknown_fields)]
441pub struct LeadershipBalanceConfig {
442    /// Tick interval for the leadership balancer.
443    pub interval: HumanDuration,
444    /// Max leader handoffs to attempt per tick.
445    pub max_per_tick: usize,
446    /// Timeout when querying peer shed state.
447    pub peer_timeout: HumanDuration,
448}
449
450impl Default for LeadershipBalanceConfig {
451    fn default() -> Self {
452        Self {
453            interval: HumanDuration::sec(5),
454            max_per_tick: 4,
455            peer_timeout: HumanDuration::milli(500),
456        }
457    }
458}
459
460/// Cluster egress probe tuning.
461#[derive(Debug, Clone, Deserialize, Serialize)]
462#[serde(default, deny_unknown_fields)]
463pub struct ClusterProbeConfig {
464    /// Tick interval for egress probes.
465    pub interval: HumanDuration,
466    /// Payload size for egress probe messages.
467    pub probe_size: HumanSize,
468    /// Timeout for individual egress probes.
469    pub timeout: HumanDuration,
470    /// Consecutive failed ticks before marking egress unhealthy.
471    pub unhealthy_ticks: usize,
472    /// Consecutive healthy ticks before clearing egress unhealthy.
473    pub heal_ticks: usize,
474}
475
476impl Default for ClusterProbeConfig {
477    fn default() -> Self {
478        Self {
479            interval: HumanDuration::milli(500),
480            probe_size: HumanSize::kib(64),
481            timeout: HumanDuration::milli(200),
482            unhealthy_ticks: 2,
483            heal_ticks: 6,
484        }
485    }
486}
487
488/// Commit-stall watchdog tuning.
489#[derive(Debug, Clone, Deserialize, Serialize)]
490#[serde(default, deny_unknown_fields)]
491pub struct CommitStallConfig {
492    /// Tick interval for the commit-stall watchdog.
493    pub interval: HumanDuration,
494    /// Duration a group must be stalled (`last_log_index > committed_index`)
495    /// before triggering a leader transfer.
496    pub threshold: HumanDuration,
497}
498
499impl Default for CommitStallConfig {
500    fn default() -> Self {
501        Self {
502            interval: HumanDuration::sec(2),
503            threshold: HumanDuration::sec(15),
504        }
505    }
506}
507
508/// Cold-storage health gate tuning.
509#[derive(Debug, Clone, Deserialize, Serialize)]
510#[serde(default, deny_unknown_fields)]
511pub struct ColdHealthConfig {
512    /// Tick interval for the cold-health gate.
513    pub interval: HumanDuration,
514    /// Consecutive unhealthy ticks before shedding leadership.
515    pub unhealthy_ticks: usize,
516    /// Consecutive healthy ticks before re-allowing leadership.
517    pub heal_ticks: usize,
518    /// High watermark for per-group hot bytes. Exceeding this contributes to
519    /// unhealthy.
520    pub hot_size_high: HumanSize,
521    /// Low watermark for per-group hot bytes. Dropping below this contributes
522    /// to healthy.
523    pub hot_size_low: HumanSize,
524    /// Error-count threshold per tick that marks cold as unhealthy.
525    pub errors_per_tick_high: usize,
526}
527
528impl Default for ColdHealthConfig {
529    fn default() -> Self {
530        Self {
531            interval: HumanDuration::sec(2),
532            unhealthy_ticks: 3,
533            heal_ticks: 5,
534            hot_size_high: HumanSize::mib(7),
535            hot_size_low: HumanSize::mib(4),
536            errors_per_tick_high: 1,
537        }
538    }
539}
540
541/// Observability and debugging features.
542#[derive(Debug, Clone, Default, Deserialize, Serialize)]
543#[serde(default, deny_unknown_fields)]
544pub struct ObservabilityConfig {
545    /// Enable tokio-console integration.
546    pub tokio_console: bool,
547}