Skip to main content

rust_supervisor/spec/
child.rs

1//! Child declaration model.
2//!
3//! This module owns declarative child specifications and validates local child
4//! invariants before the runtime registers or starts work.
5
6use crate::error::types::SupervisorError;
7use crate::id::types::ChildId;
8use crate::policy::task_role_defaults::{SeverityClass, SidecarConfig, TaskRole};
9use crate::readiness::signal::ReadinessPolicy;
10use crate::spec::shutdown::ShutdownBudget;
11use crate::task::factory::TaskFactory;
12use confique::Config;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::fmt::{Debug, Formatter};
16use std::sync::Arc;
17use std::time::Duration;
18
19/// Kind of task represented by a child declaration.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum TaskKind {
23    /// Asynchronous worker that can be cancelled through its context.
24    AsyncWorker,
25    /// Blocking worker with explicit shutdown and escalation boundaries.
26    BlockingWorker,
27    /// Nested supervisor node.
28    Supervisor,
29}
30
31impl Default for TaskKind {
32    /// Returns the default task kind: [`AsyncWorker`](TaskKind::AsyncWorker).
33    fn default() -> Self {
34        Self::AsyncWorker
35    }
36}
37
38/// Runtime isolation strategy for a child task.
39///
40/// Controls which tokio runtime the child's future is spawned into. Tasks
41/// that are prone to blocking (CPU-heavy loops, synchronous I/O) should use
42/// `BlockingPool` to avoid starving the async worker threads.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "snake_case")]
45pub enum Isolation {
46    /// Spawn on the default multi-threaded async worker pool.
47    /// Suitable for well-behaved async tasks that always yield at `.await`.
48    AsyncWorker,
49    /// Spawn on the dedicated `spawn_blocking` thread pool (up to 500 threads).
50    /// Use for tasks that may block or perform CPU-heavy work without `.await`.
51    BlockingPool,
52}
53
54impl Default for Isolation {
55    /// Returns the default isolation: [`AsyncWorker`](Isolation::AsyncWorker).
56    fn default() -> Self {
57        Self::AsyncWorker
58    }
59}
60
61/// Importance of a child to its parent supervisor.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum Criticality {
65    /// The child is required for the supervisor to remain healthy.
66    Critical,
67    /// The child can fail without forcing parent shutdown.
68    Optional,
69}
70
71impl Default for Criticality {
72    /// Returns the default criticality: [`Optional`](Criticality::Optional).
73    fn default() -> Self {
74        Self::Optional
75    }
76}
77
78/// Restart behavior attached to a child.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum RestartPolicy {
82    /// Restart regardless of the exit result.
83    Permanent,
84    /// Restart only when the task failed.
85    Transient,
86    /// Do not restart after any exit.
87    Temporary,
88}
89
90impl Default for RestartPolicy {
91    /// Returns the default restart policy: [`Permanent`](RestartPolicy::Permanent).
92    fn default() -> Self {
93        Self::Permanent
94    }
95}
96
97/// Health behavior attached to a child.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99pub struct HealthPolicy {
100    /// Expected heartbeat interval.
101    pub heartbeat_interval: Duration,
102    /// Maximum age for the last heartbeat before the child is stale.
103    pub stale_after: Duration,
104}
105
106impl HealthPolicy {
107    /// Creates a health policy.
108    ///
109    /// # Arguments
110    ///
111    /// - `heartbeat_interval`: Expected heartbeat interval.
112    /// - `stale_after`: Maximum heartbeat age.
113    ///
114    /// # Returns
115    ///
116    /// Returns a [`HealthPolicy`] value.
117    pub fn new(heartbeat_interval: Duration, stale_after: Duration) -> Self {
118        Self {
119            heartbeat_interval,
120            stale_after,
121        }
122    }
123}
124
125/// Health check configuration for a child declaration.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
127pub struct HealthCheckConfig {
128    /// Interval between health checks in seconds.
129    #[config(default = 10)]
130    #[serde(default = "default_health_check_interval_secs")]
131    pub check_interval_secs: u64,
132    /// Timeout for each health check in seconds.
133    #[config(default = 5)]
134    #[serde(default = "default_health_check_timeout_secs")]
135    pub timeout_secs: u64,
136    /// Maximum retries before marking the child as unhealthy.
137    #[config(default = 3)]
138    #[serde(default = "default_health_check_max_retries")]
139    pub max_retries: u32,
140}
141
142impl Default for HealthCheckConfig {
143    /// Returns the default health check config: 10s interval, 5s timeout, 3 retries.
144    fn default() -> Self {
145        Self {
146            check_interval_secs: default_health_check_interval_secs(),
147            timeout_secs: default_health_check_timeout_secs(),
148            max_retries: default_health_check_max_retries(),
149        }
150    }
151}
152
153/// Returns the default health check interval in seconds.
154fn default_health_check_interval_secs() -> u64 {
155    10
156}
157
158/// Returns the default health check timeout in seconds.
159fn default_health_check_timeout_secs() -> u64 {
160    5
161}
162
163/// Returns the default health check retry count.
164fn default_health_check_max_retries() -> u32 {
165    3
166}
167
168/// Command permissions granted to a child.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
170pub struct CommandPermissions {
171    /// Whether the child may trigger supervisor shutdown.
172    #[config(default = false)]
173    #[serde(default)]
174    pub allow_shutdown: bool,
175    /// Whether the child may request its own restart.
176    #[config(default = false)]
177    #[serde(default)]
178    pub allow_restart: bool,
179    /// Signals the child is allowed to send.
180    #[config(default = [])]
181    #[serde(default = "default_command_permission_signals")]
182    pub allowed_signals: Vec<String>,
183}
184
185impl Default for CommandPermissions {
186    /// Returns the default command permissions: no shutdown, no restart, SIGTERM only.
187    fn default() -> Self {
188        Self {
189            allow_shutdown: false,
190            allow_restart: false,
191            allowed_signals: default_command_permission_signals(),
192        }
193    }
194}
195
196/// Returns the default allowed signal list for command permissions.
197fn default_command_permission_signals() -> Vec<String> {
198    vec!["SIGTERM".to_string()]
199}
200
201/// Environment variable for a child.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
203pub struct EnvVar {
204    /// Environment variable name.
205    pub name: String,
206    /// Plain-text value (mutually exclusive with secret_ref).
207    pub value: Option<String>,
208    /// Secret reference in `${SECRET_NAME}` format (mutually exclusive with value).
209    pub secret_ref: Option<String>,
210}
211
212/// Secret reference for a child.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Config, JsonSchema)]
214pub struct SecretRef {
215    /// Secret name used as an identifier.
216    pub name: String,
217    /// Key path within the vault.
218    pub key: String,
219    /// Whether the secret is required (vault offline treated as rejection when true).
220    #[config(default = false)]
221    #[serde(default)]
222    pub required: bool,
223}
224
225/// Backoff behavior attached to a child.
226#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
227pub struct BackoffPolicy {
228    /// Initial delay before the first restart.
229    pub initial_delay: Duration,
230    /// Maximum restart delay.
231    pub max_delay: Duration,
232    /// Jitter ratio between zero and one.
233    pub jitter_ratio: f64,
234}
235
236impl BackoffPolicy {
237    /// Creates a backoff policy.
238    ///
239    /// # Arguments
240    ///
241    /// - `initial_delay`: Initial restart delay.
242    /// - `max_delay`: Maximum restart delay.
243    /// - `jitter_ratio`: Jitter ratio between zero and one.
244    ///
245    /// # Returns
246    ///
247    /// Returns a [`BackoffPolicy`] value.
248    pub fn new(initial_delay: Duration, max_delay: Duration, jitter_ratio: f64) -> Self {
249        Self {
250            initial_delay,
251            max_delay,
252            jitter_ratio,
253        }
254    }
255}
256
257/// Declarative specification for a child task or nested supervisor.
258#[derive(Clone, Serialize, Deserialize, JsonSchema)]
259pub struct ChildSpec {
260    /// Stable child identifier.
261    pub id: ChildId,
262    /// Human-readable child name.
263    pub name: String,
264    /// Child task kind.
265    pub kind: TaskKind,
266    /// Runtime isolation strategy (default: AsyncWorker).
267    #[serde(default)]
268    pub isolation: Isolation,
269    /// Optional factory for worker children.
270    #[serde(skip)]
271    #[schemars(skip)]
272    pub factory: Option<Arc<dyn TaskFactory>>,
273    /// Optional registry key used to resolve the worker factory before startup.
274    #[serde(default)]
275    pub factory_key: Option<String>,
276    /// Restart policy for this child.
277    pub restart_policy: RestartPolicy,
278    /// Shutdown budget for this child.
279    pub shutdown_budget: ShutdownBudget,
280    /// Health policy for this child.
281    pub health_policy: HealthPolicy,
282    /// Readiness policy for this child.
283    pub readiness_policy: ReadinessPolicy,
284    /// Backoff policy for this child.
285    pub backoff_policy: BackoffPolicy,
286    /// Child identifiers that must become ready before this child starts.
287    pub dependencies: Vec<ChildId>,
288    /// Low-cardinality tags used for grouping and diagnostics.
289    pub tags: Vec<String>,
290    /// Criticality used by parent policy decisions.
291    pub criticality: Criticality,
292    /// Optional role that selects default lifecycle policy semantics.
293    #[serde(default)]
294    pub task_role: Option<TaskRole>,
295    /// Optional sidecar binding used when the role is [`TaskRole::Sidecar`].
296    #[serde(default)]
297    pub sidecar_config: Option<SidecarConfig>,
298    /// Optional explicit severity classification that overrides the role default (US3).
299    #[serde(default)]
300    pub severity: Option<SeverityClass>,
301    /// Optional group name for group-level isolation and budget tracking (US2).
302    #[serde(default)]
303    pub group: Option<String>,
304    /// Optional health check configuration.
305    #[serde(default)]
306    pub health_check: Option<HealthCheckConfig>,
307    /// Command permissions granted to this child.
308    #[serde(default)]
309    pub command_permissions: CommandPermissions,
310    /// Environment variables for this child.
311    #[serde(default)]
312    pub environment: Vec<EnvVar>,
313    /// Secret references for this child.
314    #[serde(default)]
315    pub secrets: Vec<SecretRef>,
316    /// File system paths (sockets, PID files, temp dirs) to clean up before
317    /// every spawn attempt. Prevents "Address already in use" errors when a
318    /// previous instance was orphaned by emergency force-kill.
319    #[serde(default)]
320    pub cleanup_paths: Vec<std::path::PathBuf>,
321}
322
323impl Debug for ChildSpec {
324    /// Formats the child specification without printing the task factory.
325    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
326        formatter
327            .debug_struct("ChildSpec")
328            .field("isolation", &self.isolation)
329            .field("id", &self.id)
330            .field("name", &self.name)
331            .field("kind", &self.kind)
332            .field("restart_policy", &self.restart_policy)
333            .field("factory_key", &self.factory_key)
334            .field("shutdown_budget", &self.shutdown_budget)
335            .field("health_policy", &self.health_policy)
336            .field("readiness_policy", &self.readiness_policy)
337            .field("backoff_policy", &self.backoff_policy)
338            .field("dependencies", &self.dependencies)
339            .field("tags", &self.tags)
340            .field("criticality", &self.criticality)
341            .field("task_role", &self.task_role)
342            .field("sidecar_config", &self.sidecar_config)
343            .field("severity", &self.severity)
344            .field("group", &self.group)
345            .field("health_check", &self.health_check)
346            .field("command_permissions", &self.command_permissions)
347            .field("environment", &self.environment)
348            .field("secrets", &self.secrets)
349            .finish()
350    }
351}
352
353impl ChildSpec {
354    /// Creates a worker child specification.
355    ///
356    /// # Arguments
357    ///
358    /// - `id`: Stable child identifier.
359    /// - `name`: Human-readable child name.
360    /// - `kind`: Worker task kind.
361    /// - `factory`: Task factory used to build each child_start_count.
362    ///
363    /// # Returns
364    ///
365    /// Returns a [`ChildSpec`] with conservative policy values.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`SupervisorError`] when local invariants fail validation.
370    ///
371    /// # Examples
372    ///
373    /// ```
374    /// # fn example() -> Result<(), rust_supervisor::error::types::SupervisorError> {
375    /// let factory = rust_supervisor::task::factory::service_fn(|_ctx| async {
376    ///     rust_supervisor::task::factory::TaskResult::Succeeded
377    /// });
378    /// let spec = rust_supervisor::spec::child::ChildSpec::worker(
379    ///     rust_supervisor::id::types::ChildId::new("worker"),
380    ///     "worker",
381    ///     rust_supervisor::spec::child::TaskKind::AsyncWorker,
382    ///     std::sync::Arc::new(factory),
383    /// )?;
384    /// assert_eq!(spec.name, "worker");
385    /// # Ok(())
386    /// # }
387    /// ```
388    pub fn worker(
389        id: ChildId,
390        name: impl Into<String>,
391        kind: TaskKind,
392        factory: Arc<dyn TaskFactory>,
393    ) -> Result<Self, SupervisorError> {
394        crate::spec::child_builder::ChildSpecBuilder::worker(id, name, kind, factory).build()
395    }
396
397    /// Validates local child specification invariants.
398    ///
399    /// # Arguments
400    ///
401    /// This function has no arguments.
402    ///
403    /// # Returns
404    ///
405    /// Returns `Ok(())` when the child can be registered.
406    pub fn validate(&self) -> Result<(), SupervisorError> {
407        validate_non_empty(&self.id.value, "child id")?;
408        validate_non_empty(&self.name, "child name")?;
409        validate_tags(&self.tags)?;
410        validate_backoff(self.backoff_policy)?;
411        validate_factory(self.kind, self.factory.is_some())?;
412        validate_sidecar_local(self)
413    }
414}
415
416/// Validates a non-empty string invariant.
417///
418/// # Arguments
419///
420/// - `value`: String value being validated.
421/// - `label`: Field label used in the error message.
422///
423/// # Returns
424///
425/// Returns `Ok(())` when the string is not empty.
426fn validate_non_empty(value: &str, label: &str) -> Result<(), SupervisorError> {
427    if value.trim().is_empty() {
428        Err(SupervisorError::fatal_config(format!(
429            "{label} must not be empty"
430        )))
431    } else {
432        Ok(())
433    }
434}
435
436/// Validates tag invariants.
437///
438/// # Arguments
439///
440/// - `tags`: Tags attached to the child.
441///
442/// # Returns
443///
444/// Returns `Ok(())` when every tag is non-empty.
445fn validate_tags(tags: &[String]) -> Result<(), SupervisorError> {
446    for tag in tags {
447        validate_non_empty(tag, "child tag")?;
448    }
449    Ok(())
450}
451
452/// Validates backoff invariants.
453///
454/// # Arguments
455///
456/// - `policy`: Backoff policy attached to the child.
457///
458/// # Returns
459///
460/// Returns `Ok(())` when delay and jitter values are valid.
461fn validate_backoff(policy: BackoffPolicy) -> Result<(), SupervisorError> {
462    if policy.initial_delay > policy.max_delay {
463        return Err(SupervisorError::fatal_config(
464            "initial backoff must not exceed max backoff",
465        ));
466    }
467    if !(0.0..=1.0).contains(&policy.jitter_ratio) {
468        return Err(SupervisorError::fatal_config(
469            "jitter ratio must be between zero and one",
470        ));
471    }
472    Ok(())
473}
474
475/// Validates factory presence for the child kind.
476///
477/// # Arguments
478///
479/// - `kind`: Child task kind.
480/// - `has_factory`: Whether a factory was supplied.
481///
482/// # Returns
483///
484/// Returns `Ok(())` when factory presence matches the task kind.
485fn validate_factory(kind: TaskKind, has_factory: bool) -> Result<(), SupervisorError> {
486    match (kind, has_factory) {
487        (TaskKind::Supervisor, true) => Err(SupervisorError::fatal_config(
488            "supervisor child must not own a task factory",
489        )),
490        (TaskKind::AsyncWorker | TaskKind::BlockingWorker, false) => Err(
491            SupervisorError::fatal_config("worker child requires a task factory"),
492        ),
493        _ => Ok(()),
494    }
495}
496
497/// Validates local sidecar fields without inspecting sibling children.
498///
499/// # Arguments
500///
501/// - `child`: Child specification to validate.
502///
503/// # Returns
504///
505/// Returns `Ok(())` when the local sidecar declaration is coherent.
506fn validate_sidecar_local(child: &ChildSpec) -> Result<(), SupervisorError> {
507    match (child.task_role, child.sidecar_config.as_ref()) {
508        (Some(TaskRole::Sidecar), None) => Err(SupervisorError::fatal_config(
509            "sidecar task_role requires sidecar_config",
510        )),
511        (role, Some(_)) if role != Some(TaskRole::Sidecar) => Err(SupervisorError::fatal_config(
512            "sidecar_config requires sidecar task_role",
513        )),
514        _ => Ok(()),
515    }
516}