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}